[
  {
    "path": ".agents/skills/archunit-rules/SKILL.md",
    "content": "---\nname: archunit-rules\ndescription: ArchUnit architecture rules enforced in Xeres including common module rules (logging, utility classes), app module rules (no field injection, RsService naming), and UI module rules (WindowController naming).\n---\n\n# ArchUnit Rules for Xeres\n\nArchitecture rules are enforced via ArchUnit tests in `common/src/test/` and `common/src/testFixtures/`.\n\n## Running Rules\n\n```bash\n./gradlew test --tests \"*CodingRulesTest\"\n```\n\n## Common Module Rules (`CommonCodingRulesTest`)\n\n### Logging\n\n- No `java.util.logging` allowed\n- Use SLF4J only\n\n### Logger Declaration\n\n```java\nprivate static final Logger log;  // Correct\nLogger logger;  // Wrong\n```\n\n### Utility Classes\n\n```java\npublic final class FooUtils\n{  // Correct\n\tprivate FooUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n```\n\n### Identifier Classes\n\nMust have `public static final int LENGTH`:\n\n```java\npublic class ProfileIdentifier extends Identifier\n{\n\tpublic static final int LENGTH = 32;\n}\n```\n\n## App Module Rules (`AppCodingRulesTest`)\n\n### No Field Injection\n\n```java\n// Allowed\nprivate final ProfileService profileService;\n\npublic Service(ProfileService profileService)\n{ ...}\n\n// Forbidden\n@Autowired\nprivate ProfileService profileService;\n```\n\n### RsService Naming\n\nService subclasses must end with `RsService`:\n\n```java\npublic class AvatarRsService extends RsService\n{\n}  // Correct\n\npublic class AvatarService extends RsService\n{\n}   // Wrong\n```\n\n### JPA Entities\n\nMust have public or protected no-arg constructor:\n\n```java\n@Entity\npublic class Profile \n{\n    protected Profile() { }  // Required\n}\n```\n\n### Item Classes\n\n- Public no-arg constructor\n- `clone()` method implemented\n- Meaningful `toString()` method\n\n### No UI Access\n\n`app` module cannot access `ui` module packages:\n\n```java\nnoClasses().\n\nthat().\n\nresideInPackage(\"io.xeres.app..\")\n    .\n\nshould().\n\naccessClassesThat().\n\nresideInPackage(\"io.xeres.ui..\")\n```\n\n## UI Module Rules (`UiCodingRulesTest`)\n\n### WindowController Naming\n\n```java\npublic class SettingsWindowController\n{\n}  // Correct\n\npublic class SettingsController\n{\n}          // Wrong\n```\n\n### No FileChooser Initial Directory\n\nUse `ChooserUtils` instead:\n\n```java\n// Forbidden\nfileChooser.setInitialDirectory(path);\n\n// Use instead\nChooserUtils.\n\nsetInitialDirectory(fileChooser, path);\n```\n\n### No Field Injection\n\nSame rule as app module.\n"
  },
  {
    "path": ".agents/skills/crypto/SKILL.md",
    "content": "---\nname: crypto\ndescription: Cryptography patterns for Xeres including PGP operations, key generation, and hash functions with best practices.\n---\n\n# Cryptography Patterns for Xeres\n\n## JCE/JCA and BouncyCastle Usage\n\nXeres uses JCE/JCA and BouncyCastle for cryptographic operations. Always use the registered providers.\n\n## Common Patterns\n\n### OpenPGP Operations\n\n```java\nimport org.bouncycastle.openpgp.*;\n\nPGPSecretKeyRingCollection secretKeys = ...\nPGPPublicKeyRingCollection publicKeys = ...\n\n// Encrypt\nPGPEncryptedDataGenerator encryptedDataGenerator = new PGPEncryptedDataGenerator(\n\t\tnew JcePGPDataEncryptorBuilder(PGPEncryptedData.CAST5)\n\t\t\t\t.setWithIntegrityPacket(true)\n\t\t\t\t.setSecureRandom(new SecureRandom())\n\t\t\t\t.useInsecureRandom() // Only for testing\n);\n\n// Decrypt\nPGPPrivateKey privateKey = secretKeys.getSecretKey(keyId)\n\t\t.extractPrivateKey(new JcePBESecretKeyDecryptorBuilder()\n\t\t\t\t.setProvider(\"BC\")\n\t\t\t\t.build(passphrase.toCharArray()));\n```\n\n### Key Generation\n\n```java\nimport org.bouncycastle.bcpg.*;\nimport org.bouncycastle.openpgp.*;\n\nvar keyRingGenerator = new PGPKeyRingGenerator(\n\t\tV3PGPSignature.POSITIVE_CERTIFICATION,\n\t\tnew PGPSignatureSubpacketGenerator(),\n\t\talgorithm,\n\t\tencryptionKey,\n\t\tcreationTime,\n\t\t\"User ID\",\n\t\tsymmetricKeyEncryption,\n\t\thashedGen,\n\t\tunhashedGen,\n\t\tnew SecureRandom(),\n\t\t\"BC\"\n);\n```\n\n### Hash Functions\n\n```java\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\n\nimport java.security.MessageDigest;\n\nSecurity.addProvider(new BouncyCastleProvider());\nMessageDigest digest = MessageDigest.getInstance(\"SHA-256\", \"BC\");\nbyte[] hash = digest.digest(data);\n```\n\n## Best Practices\n\n1. Use the `SecureRandomUtils` class for all random operations\n2. Prefer JCA/JCE, otherwise use BouncyCastle\n3. Use constant-time comparisons for secrets\n4. Clear sensitive data from memory when done\n5. Use appropriate key sizes (RSA 2048+)\n\n## Identifier Classes\n\nCryptographic identifiers extend `Identifier`:\n\n```java\npublic class RsPkIdentifier extends Identifier\n{\n\tpublic static final int LENGTH = 16;\n}\n```\n"
  },
  {
    "path": ".agents/skills/dto-mappers/SKILL.md",
    "content": "---\nname: dto-mappers\ndescription: DTO and mapper patterns for Xeres using Java records, canonical constructors with validation, and static mapper utility classes.\n---\n\n# DTO and Mapper Patterns for Xeres\n\n## DTOs as Records (Java 21+)\n\nUse Java records for immutable DTOs:\n\n```java\npublic record ProfileDTO(\n\t\tlong id,\n\t\t@NotNull @Size String name,\n\t\tString pgpIdentifier,\n\t\tInstant created,\n\t\tbyte[] pgpFingerprint,\n\t\tbyte[] pgpPublicKeyData,\n\t\tboolean accepted,\n\t\tTrust trust,\n\t\t@JsonInclude(NON_EMPTY) List<LocationDTO> locations\n)\n{\n\tpublic ProfileDTO\n\t{\n\t\tif (locations == null) locations = new ArrayList<>();\n\t}\n}\n```\n\n## Canonical Constructor with Validation\n\n```java\npublic record ProfileDTO(...)\n{\n\tpublic ProfileDTO\n\t{\n\t\tObjects.requireNonNull(name, \"Name must not be null\");\n\t\tif (locations == null)\n\t\t{\n\t\t\tlocations = new ArrayList<>();\n\t\t}\n\t\tlocations = List.copyOf(locations);  // Make immutable\n\t}\n}\n```\n\n## Mapper Pattern\n\nStatic utility class with mapping methods:\n\n```java\npublic final class ProfileMapper\n{\n\tprivate ProfileMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ProfileDTO toDTO(Profile profile)\n\t{\n\t\tif (profile == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new ProfileDTO(\n\t\t\t\tprofile.getId(),\n\t\t\t\tprofile.getName(),\n\t\t\t\t// ... other fields\n\t\t);\n\t}\n\n\tpublic static Profile toEntity(ProfileDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\tvar profile = new Profile();\n\t\tprofile.setId(dto.id());\n\t\tprofile.setName(dto.name());\n\t\t// ... other fields\n\t\treturn profile;\n\t}\n}\n```\n\n## Usage\n\n```java\n// Entity to DTO\nProfileDTO dto = ProfileMapper.toDTO(profile);\n\n// DTO to Entity\nProfile profile = ProfileMapper.toEntity(dto);\n\n// List mapping\nList<ProfileDTO> dtos = profiles.stream()\n\t\t.map(ProfileMapper::toDTO)\n\t\t.toList();\n```\n\n## JsonInclude for Optional Fields\n\n```java\n\n@JsonInclude(NON_EMPTY)  // Don't serialize null or empty collections\nList<LocationDTO> locations\n```\n\n## Validation Annotations\n\nUse Bean Validation on DTO fields:\n\n```java\n\n@NotNull\n@Size(min = 1, max = 255)\nString name\n@Email\nString email\n@Min(0)\n@Max(100)\nint percentage\n```\n\n## Collection Handling\n\nAlways handle null collections in constructor:\n\n```java\npublic ProfileDTO\n{\n\tif (locations == null)\n\t{\n\t\tlocations = new ArrayList<>();\n\t}\n}\n```\n"
  },
  {
    "path": ".agents/skills/flyway-migrations/SKILL.md",
    "content": "---\nname: flyway-migrations\ndescription: Flyway SQL migration patterns for Xeres including naming conventions, H2 database patterns, enum types, foreign keys, and best practices.\n---\n\n# Flyway Migration Patterns for Xeres\n\n## Migration Location\n\n`app/src/main/resources/db/migration/`\n\n## Naming Convention\n\n`V<major>_<minor>_<timestamp>__<description>.sql`\n\nExamples:\n\n- `V00_0_1_202001232214__InitDb.sql`\n- `V00_0_32_202603092327__AddIndices.sql`\n\n## Table Creation Pattern\n\n```sql\nCREATE TABLE profile (\n    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    name        VARCHAR(255) NOT NULL,\n    pgp_id      BIGINT,\n    created     TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    CONSTRAINT profile_pkey PRIMARY KEY (id)\n);\n\nCREATE INDEX idx_profile_name ON profile(name);\n```\n\n## Enum Types\n\n```sql\nCREATE TYPE trust_level AS ENUM ('UNKNOWN', 'NEVER', 'MARGINAL', 'FULLY', 'ULTIMATE');\n\nALTER TABLE profile ADD COLUMN trust trust_level NOT NULL DEFAULT 'UNKNOWN';\n```\n\n## Foreign Keys\n\n```sql\nALTER TABLE contact\n    ADD CONSTRAINT fk_contact_profile\n    FOREIGN KEY (profile_id) REFERENCES profile(id)\n    ON DELETE CASCADE;\n```\n\n## Best Practices\n\n1. **Separate index creation** from table creation\n2. **Name all constraints** explicitly for easier debugging\n3. **Use `BIGINT` for IDs** with `GENERATED BY DEFAULT AS IDENTITY`\n4. **Include `NOT NULL`** constraints where appropriate\n5. **Default values** for optional columns\n6. **One concern per migration** when possible\n7. **Timestamp precision** must always be of TIMESTAMP(9)\n\n## Rolling Back\n\nAdd downward migrations with `V<version>__<name>.sql` (no timestamp):\n\n```sql\n-- V00_0_33__AddLocations.sql\nALTER TABLE location DROP COLUMN IF EXISTS last_seen;\n```\n\n## Testing Migrations\n\nFlyway runs automatically on application startup with H2 database.\n"
  },
  {
    "path": ".agents/skills/gradle-build/SKILL.md",
    "content": "---\nname: gradle-build\ndescription: Gradle build configuration for Xeres including build commands, version management, module structure, and key plugins.\n---\n\n# Gradle Build for Xeres\n\n## Project Structure\n\nMulti-module Gradle project:\n\n```\nXeres/\n├── app/          - Spring Boot application\n├── ui/           - JavaFX desktop UI\n├── common/       - Shared code\n├── build.gradle  - Root configuration\n└── settings.gradle\n```\n\n## Build Commands\n\n```bash\n# Run the application\n./gradlew bootRun\n\n# Build without tests\n./gradlew build -x test\n\n# Run tests\n./gradlew test\n\n# Run UI tests specifically\n./gradlew :ui:test\n\n# Package application (MSI on Windows, .deb on Linux)\n./gradlew :app:jpackage\n\n# Create portable zip\n./gradlew :app:jpackage -Pjpackage.portable=true\n\n# Build Docker image\n./gradlew :app:bootBuildImage\n\n# Clean build\n./gradlew clean\n```\n\n## Version Management\n\nVersions are defined in root `build.gradle` ext block:\n\n```groovy\next {\n\tset('version.java', 25)\n\tset('version.spring-boot', '4.0.5')\n\t// etc.\n}\n```\n\nNever modify version numbers directly. Update in root build.gradle.\n\n## Module Dependencies\n\n```\napp    → common\nui     → common\napp    ✗→ ui (forbidden by archunit)\n```\n\n## Key Plugins\n\n- `java` - Java compilation\n- `application` - Runnable application\n- `org.springframework.boot` - Spring Boot\n- `io.github.goooler.java` - BOM management\n- `jacoco` - Code coverage\n- `org.openjfx.javafxplugin` - JavaFX\n\n## Subproject Configuration\n\nSubprojects inherit common configuration from root build.gradle. Module-specific settings go in `app/build.gradle`, `ui/build.gradle`, etc.\n\n## Running Application\n\n```bash\n# Development mode with hot reload\n./gradlew bootRun\n\n# With specific JVM args\n./gradlew bootRun -PjvmArgs=\"-Xmx512m\"\n```\n"
  },
  {
    "path": ".agents/skills/java-conventions/SKILL.md",
    "content": "---\nname: java-conventions\ndescription: Code style, naming conventions, license headers, and patterns for Xeres Java project. Covers Allman braces, utility classes, package structure, and field injection rules.\n---\n\n# Java Conventions for Xeres\n\n## Code Style\n\n- **Brace Style**: Allman (braces on next line)\n- **Indentation**: tabs only\n- **Max line length**: 320 characters\n\n## License Header\n\nEvery source file must include the GPL v3 header:\n\n```java\n/*\n * Copyright (c) [year-range] by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation...\n */\n```\n\n## Version\n\nThe version of Java used in Java 25.\n\n## Utility Classes\n\n- Must be `final` class\n- Private no-arg constructor that throws `UnsupportedOperationException`\n- No instance fields\n\n```java\npublic final class FooUtils\n{\n\tprivate ProfileMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void doSomething(int count)\n\t{ ...}\n}\n```\n\n## Naming Conventions\n\n| Type                | Pattern                                        |\n|---------------------|------------------------------------------------|\n| Services            | `*Service.java`                                |\n| Controllers         | `*Controller.java` or `*WindowController.java` |\n| REST Controllers    | `*Controller.java` in `api/controller/`        |\n| Client classes      | `*Client.java`                                 |\n| Utility classes     | `*Utils.java`                                  |\n| Fakes/Test fixtures | `*Fakes.java`                                  |\n| Mappers             | `*Mapper.java` (static utility class)          |\n\n## Package Structure\n\n`io.xeres.<module>.<feature>`\n\n| Module | Packages                                                                                    |\n|--------|---------------------------------------------------------------------------------------------|\n| app    | `api`, `application`, `configuration`, `crypto`, `database`, `job`, `net`, `service`, `xrs` |\n| ui     | `client`, `controller`, `custom`, `event`, `model`, `support`                               |\n| common | `dto`, `events`, `id`, `message`, `rest`, `protocol`                                        |\n\n## Logging\n\n- Use SLF4J (not `java.util.logging`)\n- Logger declaration: `private static final Logger log = LoggerFactory.getLogger(ClassName.class);`\n- Logging sensitive data is fine with the `debug` facility\n\n## Field Injection\n\nField injection is prohibited. Use constructor injection instead:\n\n```java\n// Bad\n@Autowired\nprivate ProfileService profileService;\n\n// Good\nprivate final ProfileService profileService;\n\npublic ContactService(ProfileService profileService)\n{\n\tthis.profileService = profileService;\n}\n```\n"
  },
  {
    "path": ".agents/skills/javafx-patterns/SKILL.md",
    "content": "---\nname: javafx-patterns\ndescription: JavaFX patterns for Xeres including controller structure with FXML views, WindowController lifecycle, WindowManager usage, and JavaFX-Spring integration.\n---\n\n# JavaFX Patterns for Xeres\n\n## Controller Structure\n\nControllers are Spring components with FXML views:\n\n```java\n\n@Component\n@FxmlView(value = \"/view/contact/contact_view.fxml\")\npublic class ContactViewController implements Controller\n{\n\t@FXML\n\tprivate TreeTableView<Contact> contactTreeTableView;\n\n\t@Override\n\tpublic void initialize()\n\t{ ...}\n}\n```\n\n## Window Controllers\n\nFor windows/dialogs, implement `WindowController` interface:\n\n```java\n\n@Component\n@FxmlView(value = \"/view/settings/settings_window.fxml\")\npublic class SettingsWindowController implements WindowController\n{\n\n\t@Override\n\tpublic void onShowing()\n\t{ ...}\n\n\t@Override\n\tpublic void onShown()\n\t{ ...}\n\n\t@Override\n\tpublic void onClose()\n\t{ ...}\n}\n```\n\nNaming convention: `*WindowController`\n\n## Window Management\n\nUse `WindowManager` for window lifecycle.\n\n## JavaFX-App Integration\n\n```java\npublic class JavaFxApplication extends Application\n{\n\tprivate ConfigurableApplicationContext springContext;\n\n\t@Override\n\tpublic void init()\n\t{\n\t\tspringContext = new SpringApplicationBuilder()\n\t\t\t\t.sources(springApplicationClass)\n\t\t\t\t.headless(false)\n\t\t\t\t.initializers(initializers())\n\t\t\t\t.run(getParameters().getRaw().toArray(new String[0]));\n\t}\n}\n```\n\n## File Choosers\n\nNever call `FileChooser.setInitialDirectory()` directly. Use `ChooserUtils`:\n\n```java\n// Bad\nfileChooser.setInitialDirectory(someDirectory);\n\n// Good\nChooserUtils.\n\nsetInitialDirectory(fileChooser, someDirectory);\n```\n\n## FXML Location Convention\n\n```\nui/src/main/resources/view/<feature>/<feature>_view.fxml\nui/src/main/resources/view/<feature>/<feature>_window.fxml (for dialogs)\n```\n\n## Binding Patterns\n\nUse JavaFX properties for observable data:\n\n```java\nprivate final StringProperty nameProperty = new SimpleStringProperty();\nprivate final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<>();\n```\n\n## UI Event Handling\n\nDispatch events through Spring's `ApplicationEventPublisher`:\n\n```java\npublic record ContactSelectedEvent(Contact contact)\n{\n}\n```\n"
  },
  {
    "path": ".agents/skills/junit-testing/SKILL.md",
    "content": "---\nname: junit-testing\ndescription: JUnit 6 testing patterns for Xeres including Mockito with constructor injection, test fixtures via *Fakes.java classes, and AssertJ assertions.\n---\n\n# JUnit Testing Patterns for Xeres\n\n## Test Structure\n\n- Location: `src/test/java/` mirrors main source structure\n- Naming: `*Test.java` suffix\n- Framework: JUnit 6 with Jupiter\n\n## Mockito Extension Pattern\n\n```java\n\n@ExtendWith(MockitoExtension.class)\nclass ContactServiceTest\n{\n\t@Mock\n\tprivate ProfileService profileService;\n\n\t@InjectMocks\n\tprivate ContactService contactService;\n\n\t@Test\n\tvoid getContacts_ShouldReturnCombinedList()\n\t{\n\t\twhen(profileService.getProfiles()).thenReturn(List.of());\n\n\t\tvar result = contactService.getContacts();\n\n\t\tassertTrue(result.isEmpty());\n\t}\n}\n```\n\n## Key Points\n\n- Use constructor injection (Mockito injects via `@InjectMocks`)\n- `@Mock` creates a mock, `@Spy` creates a partial mock\n- `when(...).thenReturn(...)` for stubbing\n- `verify(...).method()` for interaction testing\n- Prefer JUnit assertions: `assertTrue(...)` but it's also possible to use assertJ for more complex cases\n\n## Test Fixtures\n\nUse `*Fakes.java` in `common/src/testFixtures/java/io/xeres/`:\n\n```java\npublic final class ProfileFakes\n{\n\tprivate ProfileFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Profile createProfile()\n\t{\n\t\treturn createProfile(1L, \"Test Profile\");\n\t}\n\n\tpublic static Profile createProfile(long id, String name)\n\t{\n\t\tvar profile = new Profile();\n\t\tprofile.setId(id);\n\t\tprofile.setName(name);\n\t\treturn profile;\n\t}\n\n\tpublic static Profile createOwnProfile()\n\t{\n\t\tvar profile = createProfile();\n\t\tprofile.setOwn(true);\n\t\treturn profile;\n\t}\n}\n```\n\n## Assertion Examples\n\n```java\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.assertj.core.api.Assertions.*;\n\nassertNotNull(result);\n\nassertEquals(\"Test\",result.getName);\n\nassertThat(list).\n\nhasSize(2).\n\ncontains(profile1, profile2);\n\nassertThatThrownBy(() ->service.\n\nsave(null))\n\t\t.\n\nisInstanceOf(IllegalArgumentException .class);\n```\n\n## Exception Testing\n\n```java\n\n@Test\nvoid save_WithNullProfile_ShouldThrow()\n{\n\tassertThatThrownBy(() -> contactService.save(null))\n\t\t\t.isInstanceOf(NullPointerException.class)\n\t\t\t.hasMessage(\"Profile must not be null\");\n}\n```\n\n## See Also\n\n- `ui-testing` skill for JavaFX controller testing\n- `archunit-rules` skill for testing architecture rules\n"
  },
  {
    "path": ".agents/skills/spring-boot-patterns/SKILL.md",
    "content": "---\nname: spring-boot-patterns\ndescription: Spring Boot patterns for Xeres including constructor injection, @Transactional boundaries, REST controllers with OpenAPI annotations, and reactive WebClient usage in the UI module.\n---\n\n# Spring Boot Patterns for Xeres\n\n## Application Entry Point\n\n```java\n\n@SpringBootApplication(scanBasePackageClasses = {\n\t\tio.xeres.app.XeresApplication.class,\n\t\tio.xeres.ui.UiStarter.class\n})\npublic class XeresApplication\n```\n\n## Service Patterns\n\n### Constructor Injection\n\nAlways use constructor injection. Dependencies are `final`.\n\n```java\n\n@Service\npublic class ContactService\n{\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\n\tpublic ContactService(ProfileService profileService, LocationService locationService)\n\t{\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t}\n}\n```\n\n### Transactional Boundaries\n\n```java\n\n@Transactional(readOnly = true)\npublic List<Contact> getContacts()\n{ ...}\n\n@Transactional\npublic void saveContact(Contact contact)\n{ ...}\n```\n\n### Circular Dependencies\n\nUse `@Lazy` annotation, but avoid them if possible:\n\n```java\npublic ContactService(@Lazy ProfileService profileService)\n{ ...}\n```\n\n## REST Controllers\n\n### OpenAPI Annotations\n\n```java\n\n@RestController\n@Tag(name = \"Profiles\", description = \"Profile management\")\npublic class ProfileController\n{\n\t@GetMapping(\"/{id}\")\n\t@Operation(summary = \"Get profile by ID\")\n\t@ApiResponse(responseCode = \"200\", description = \"Profile found\")\n\tpublic ResponseEntity<ProfileDTO> getProfile(@PathVariable long id)\n\t{ ...}\n}\n```\n\n### Exception Handling\n\nUse custom exceptions with appropriate HTTP status codes.\n\n## Reactive WebClient Clients (UI Module)\n\n```java\n\n@Component\npublic class ProfileClient\n{\n\tprivate WebClient webClient;\n\n\t@EventListener\n\tpublic void init(StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + PROFILES_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<Profile> findById(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Profile.class);\n\t}\n}\n```\n\n## Configuration Classes\n\nUse `@Configuration` for feature-specific beans. Keep `@Bean` methods small and focused.\n\n## Testing Services\n\nSee `junit-testing` skill for testing patterns with mocks.\n"
  },
  {
    "path": ".agents/skills/ui-testing/SKILL.md",
    "content": "---\nname: ui-testing\ndescription: TestFX patterns for JavaFX controller testing in Xeres including FXML loading with controller factories, mocking reactive clients, and user interaction testing.\n---\n\n# UI Testing Patterns for Xeres\n\n## TestFX Setup\n\nUI tests use TestFX with both `ApplicationExtension` and `MockitoExtension`:\n\n```java\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass ContactViewControllerTest\n{\n\t@Mock\n\tprivate ProfileClient profileClient;\n\n\t@InjectMocks\n\tprivate ContactViewController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(getClass().getResource(\"/view/contact/contact_view.fxml\"));\n\t\tloader.setControllerFactory(_ -> controller);\n\t\tParent root = loader.load();\n\t\tassertThat(root).isNotNull();\n\t}\n}\n```\n\n## FXTest Base Class\n\nFor tests requiring JavaFX initialization, extend `FXTest`:\n\n```java\nclass SomeJavaFXTest extends FXTest\n{\n\t@Test\n\tvoid test\n\n\tjavafx components()\n\t{\n\t\t// JavaFX is initialized\n\t}\n}\n```\n\n## FXML Loading Pattern\n\n```java\n\n@Test\nvoid initialize_ShouldLoadContacts() throws IOException\n{\n\t// Load FXML with controller factory\n\tFXMLLoader loader = new FXMLLoader(\n\t\t\tgetClass().getResource(\"/view/contact/contact_view.fxml\")\n\t);\n\tloader.setControllerFactory(javaClass -> controller);\n\n\t// Initialize controller manually for unit-like tests\n\tcontroller.initialize();\n\n\t// Verify initial state\n\tassertThat(controller.getContactTreeTableView()).isNotNull();\n}\n```\n\n## Testing User Interactions\n\n```java\n\n@Test\nvoid clickButton_ShouldTriggerAction()\n{\n\t// Find button in loaded FXML\n\tvar button = lookup(\"#saveButton\").query();\n\n\t// Click and verify\n\tclickOn(button);\n\n\t// Verify interaction with mock\n\tverify(profileClient).save(any(Profile.class));\n}\n```\n\n## Mocking Reactive Clients\n\nFor WebClient-based clients returning `Mono`:\n\n```java\nwhen(profileClient.findById(anyLong()))\n\t\t.\n\nthenReturn(Mono.just(testProfile));\n```\n\n## See Also\n\n- `junit-testing` skill for basic testing patterns\n- `javafx-patterns` skill for controller structure\n"
  },
  {
    "path": ".aiignore",
    "content": "/data*/\n/.idea/\n/.vscode/\n/.gradle/\n/.venv/\n./jpb/\n/*/build/\n/build/\n/*/out/\n/*/bin/\n.xeres.lock\n/.jpb/persistence-units.xml\n.run/XeresApplication.run.xml\n/scripts/bot/config.json\n/scripts/bot/avatar.png\n/cache/\n/.proxyai/"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 4\nindent_style = tab\ninsert_final_newline = false\nmax_line_length = 320\ntab_width = 4\nij_continuation_indent_size = 8\nij_formatter_off_tag = @formatter:off\nij_formatter_on_tag = @formatter:on\nij_formatter_tags_enabled = false\nij_smart_tabs = true\nij_wrap_on_typing = false\n\n[*.css]\nindent_style = space\nij_smart_tabs = false\nij_css_align_closing_brace_with_properties = false\nij_css_blank_lines_around_nested_selector = 1\nij_css_blank_lines_between_blocks = 1\nij_css_enforce_quotes_on_format = false\nij_css_hex_color_long_format = false\nij_css_hex_color_lower_case = false\nij_css_hex_color_short_format = false\nij_css_hex_color_upper_case = false\nij_css_keep_blank_lines_in_code = 2\nij_css_keep_indents_on_empty_lines = false\nij_css_keep_single_line_blocks = false\nij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_css_space_after_colon = true\nij_css_space_before_opening_brace = true\nij_css_use_double_quotes = true\n\n[*.java]\nij_java_align_consecutive_assignments = false\nij_java_align_consecutive_variable_declarations = false\nij_java_align_group_field_declarations = false\nij_java_align_multiline_annotation_parameters = false\nij_java_align_multiline_array_initializer_expression = false\nij_java_align_multiline_assignment = false\nij_java_align_multiline_binary_operation = false\nij_java_align_multiline_chained_methods = false\nij_java_align_multiline_extends_list = false\nij_java_align_multiline_for = true\nij_java_align_multiline_method_parentheses = false\nij_java_align_multiline_parameters = true\nij_java_align_multiline_parameters_in_calls = false\nij_java_align_multiline_parenthesized_expression = false\nij_java_align_multiline_resources = true\nij_java_align_multiline_ternary_operation = false\nij_java_align_multiline_text_blocks = false\nij_java_align_multiline_throws_list = false\nij_java_align_subsequent_simple_methods = false\nij_java_align_throws_keyword = false\nij_java_annotation_parameter_wrap = off\nij_java_array_initializer_new_line_after_left_brace = false\nij_java_array_initializer_right_brace_on_new_line = false\nij_java_array_initializer_wrap = off\nij_java_assert_statement_colon_on_next_line = false\nij_java_assert_statement_wrap = off\nij_java_assignment_wrap = off\nij_java_binary_operation_sign_on_next_line = false\nij_java_binary_operation_wrap = off\nij_java_blank_lines_after_anonymous_class_header = 0\nij_java_blank_lines_after_class_header = 0\nij_java_blank_lines_after_imports = 1\nij_java_blank_lines_after_package = 1\nij_java_blank_lines_around_class = 1\nij_java_blank_lines_around_field = 0\nij_java_blank_lines_around_field_in_interface = 0\nij_java_blank_lines_around_initializer = 1\nij_java_blank_lines_around_method = 1\nij_java_blank_lines_around_method_in_interface = 1\nij_java_blank_lines_before_class_end = 0\nij_java_blank_lines_before_imports = 1\nij_java_blank_lines_before_method_body = 0\nij_java_blank_lines_before_package = 0\nij_java_block_brace_style = next_line\nij_java_block_comment_at_first_column = true\nij_java_call_parameters_new_line_after_left_paren = false\nij_java_call_parameters_right_paren_on_new_line = false\nij_java_call_parameters_wrap = off\nij_java_case_statement_on_separate_line = true\nij_java_catch_on_new_line = true\nij_java_class_annotation_wrap = split_into_lines\nij_java_class_brace_style = next_line\nij_java_class_count_to_use_import_on_demand = 5\nij_java_class_names_in_javadoc = 1\nij_java_do_not_indent_top_level_class_members = false\nij_java_do_not_wrap_after_single_annotation = false\nij_java_do_while_brace_force = never\nij_java_doc_add_blank_line_after_description = true\nij_java_doc_add_blank_line_after_param_comments = false\nij_java_doc_add_blank_line_after_return = false\nij_java_doc_add_p_tag_on_empty_lines = true\nij_java_doc_align_exception_comments = true\nij_java_doc_align_param_comments = true\nij_java_doc_do_not_wrap_if_one_line = false\nij_java_doc_enable_formatting = true\nij_java_doc_enable_leading_asterisks = true\nij_java_doc_indent_on_continuation = false\nij_java_doc_keep_empty_lines = true\nij_java_doc_keep_empty_parameter_tag = true\nij_java_doc_keep_empty_return_tag = true\nij_java_doc_keep_empty_throws_tag = true\nij_java_doc_keep_invalid_tags = true\nij_java_doc_param_description_on_new_line = false\nij_java_doc_preserve_line_breaks = false\nij_java_doc_use_throws_not_exception_tag = true\nij_java_else_on_new_line = true\nij_java_entity_dd_suffix = EJB\nij_java_entity_eb_suffix = Bean\nij_java_entity_hi_suffix = Home\nij_java_entity_lhi_prefix = Local\nij_java_entity_lhi_suffix = Home\nij_java_entity_li_prefix = Local\nij_java_entity_pk_class = java.lang.String\nij_java_entity_vo_suffix = VO\nij_java_enum_constants_wrap = off\nij_java_extends_keyword_wrap = off\nij_java_extends_list_wrap = off\nij_java_field_annotation_wrap = split_into_lines\nij_java_finally_on_new_line = true\nij_java_for_brace_force = never\nij_java_for_statement_new_line_after_left_paren = false\nij_java_for_statement_right_paren_on_new_line = false\nij_java_for_statement_wrap = off\nij_java_generate_final_locals = false\nij_java_generate_final_parameters = false\nij_java_if_brace_force = never\nij_java_imports_layout = *,|,javax.**,java.**,|,$*\nij_java_indent_case_from_switch = true\nij_java_insert_inner_class_imports = false\nij_java_insert_override_annotation = true\nij_java_keep_blank_lines_before_right_brace = 2\nij_java_keep_blank_lines_between_package_declaration_and_header = 2\nij_java_keep_blank_lines_in_code = 2\nij_java_keep_blank_lines_in_declarations = 2\nij_java_keep_control_statement_in_one_line = true\nij_java_keep_first_column_comment = true\nij_java_keep_indents_on_empty_lines = false\nij_java_keep_line_breaks = true\nij_java_keep_multiple_expressions_in_one_line = false\nij_java_keep_simple_blocks_in_one_line = false\nij_java_keep_simple_classes_in_one_line = false\nij_java_keep_simple_lambdas_in_one_line = false\nij_java_keep_simple_methods_in_one_line = false\nij_java_label_indent_absolute = false\nij_java_label_indent_size = 0\nij_java_lambda_brace_style = end_of_line\nij_java_layout_static_imports_separately = true\nij_java_line_comment_add_space = false\nij_java_line_comment_at_first_column = true\nij_java_message_dd_suffix = EJB\nij_java_message_eb_suffix = Bean\nij_java_method_annotation_wrap = split_into_lines\nij_java_method_brace_style = next_line\nij_java_method_call_chain_wrap = off\nij_java_method_parameters_new_line_after_left_paren = false\nij_java_method_parameters_right_paren_on_new_line = false\nij_java_method_parameters_wrap = off\nij_java_modifier_list_wrap = false\nij_java_names_count_to_use_import_on_demand = 3\nij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.*\nij_java_parameter_annotation_wrap = off\nij_java_parentheses_expression_new_line_after_left_paren = false\nij_java_parentheses_expression_right_paren_on_new_line = false\nij_java_place_assignment_sign_on_next_line = false\nij_java_prefer_longer_names = true\nij_java_prefer_parameters_wrap = false\nij_java_repeat_synchronized = true\nij_java_replace_instanceof_and_cast = false\nij_java_replace_null_check = true\nij_java_replace_sum_lambda_with_method_ref = true\nij_java_resource_list_new_line_after_left_paren = false\nij_java_resource_list_right_paren_on_new_line = false\nij_java_resource_list_wrap = off\nij_java_session_dd_suffix = EJB\nij_java_session_eb_suffix = Bean\nij_java_session_hi_suffix = Home\nij_java_session_lhi_prefix = Local\nij_java_session_lhi_suffix = Home\nij_java_session_li_prefix = Local\nij_java_session_si_suffix = Service\nij_java_space_after_closing_angle_bracket_in_type_argument = false\nij_java_space_after_colon = true\nij_java_space_after_comma = true\nij_java_space_after_comma_in_type_arguments = true\nij_java_space_after_for_semicolon = true\nij_java_space_after_quest = true\nij_java_space_after_type_cast = true\nij_java_space_before_annotation_array_initializer_left_brace = false\nij_java_space_before_annotation_parameter_list = false\nij_java_space_before_array_initializer_left_brace = false\nij_java_space_before_catch_keyword = true\nij_java_space_before_catch_left_brace = true\nij_java_space_before_catch_parentheses = true\nij_java_space_before_class_left_brace = true\nij_java_space_before_colon = true\nij_java_space_before_colon_in_foreach = true\nij_java_space_before_comma = false\nij_java_space_before_do_left_brace = true\nij_java_space_before_else_keyword = true\nij_java_space_before_else_left_brace = true\nij_java_space_before_finally_keyword = true\nij_java_space_before_finally_left_brace = true\nij_java_space_before_for_left_brace = true\nij_java_space_before_for_parentheses = true\nij_java_space_before_for_semicolon = false\nij_java_space_before_if_left_brace = true\nij_java_space_before_if_parentheses = true\nij_java_space_before_method_call_parentheses = false\nij_java_space_before_method_left_brace = true\nij_java_space_before_method_parentheses = false\nij_java_space_before_opening_angle_bracket_in_type_parameter = false\nij_java_space_before_quest = true\nij_java_space_before_switch_left_brace = true\nij_java_space_before_switch_parentheses = true\nij_java_space_before_synchronized_left_brace = true\nij_java_space_before_synchronized_parentheses = true\nij_java_space_before_try_left_brace = true\nij_java_space_before_try_parentheses = true\nij_java_space_before_type_parameter_list = false\nij_java_space_before_while_keyword = true\nij_java_space_before_while_left_brace = true\nij_java_space_before_while_parentheses = true\nij_java_space_inside_one_line_enum_braces = false\nij_java_space_within_empty_array_initializer_braces = false\nij_java_space_within_empty_method_call_parentheses = false\nij_java_space_within_empty_method_parentheses = false\nij_java_spaces_around_additive_operators = true\nij_java_spaces_around_assignment_operators = true\nij_java_spaces_around_bitwise_operators = true\nij_java_spaces_around_equality_operators = true\nij_java_spaces_around_lambda_arrow = true\nij_java_spaces_around_logical_operators = true\nij_java_spaces_around_method_ref_dbl_colon = false\nij_java_spaces_around_multiplicative_operators = true\nij_java_spaces_around_relational_operators = true\nij_java_spaces_around_shift_operators = true\nij_java_spaces_around_type_bounds_in_type_parameters = true\nij_java_spaces_around_unary_operator = false\nij_java_spaces_within_angle_brackets = false\nij_java_spaces_within_annotation_parentheses = false\nij_java_spaces_within_array_initializer_braces = false\nij_java_spaces_within_braces = false\nij_java_spaces_within_brackets = false\nij_java_spaces_within_cast_parentheses = false\nij_java_spaces_within_catch_parentheses = false\nij_java_spaces_within_for_parentheses = false\nij_java_spaces_within_if_parentheses = false\nij_java_spaces_within_method_call_parentheses = false\nij_java_spaces_within_method_parentheses = false\nij_java_spaces_within_parentheses = false\nij_java_spaces_within_switch_parentheses = false\nij_java_spaces_within_synchronized_parentheses = false\nij_java_spaces_within_try_parentheses = false\nij_java_spaces_within_while_parentheses = false\nij_java_special_else_if_treatment = true\nij_java_subclass_name_suffix = Impl\nij_java_ternary_operation_signs_on_next_line = false\nij_java_ternary_operation_wrap = off\nij_java_test_name_suffix = Test\nij_java_throws_keyword_wrap = off\nij_java_throws_list_wrap = off\nij_java_use_external_annotations = false\nij_java_use_fq_class_names = false\nij_java_use_relative_indents = false\nij_java_use_single_class_imports = true\nij_java_variable_annotation_wrap = off\nij_java_visibility = public\nij_java_while_brace_force = always\nij_java_while_on_new_line = false\nij_java_wrap_comments = false\nij_java_wrap_first_method_in_call_chain = false\nij_java_wrap_long_lines = false\n\n[.editorconfig]\nij_editorconfig_align_group_field_declarations = false\nij_editorconfig_space_after_colon = false\nij_editorconfig_space_after_comma = true\nij_editorconfig_space_before_colon = false\nij_editorconfig_space_before_comma = false\nij_editorconfig_spaces_around_assignment_operators = true\n\n[{*.gant,*.groovy,*.gradle,*.gdsl,*.gy}]\nindent_style = space\nij_smart_tabs = false\nij_groovy_align_group_field_declarations = false\nij_groovy_align_multiline_array_initializer_expression = false\nij_groovy_align_multiline_assignment = false\nij_groovy_align_multiline_binary_operation = false\nij_groovy_align_multiline_chained_methods = false\nij_groovy_align_multiline_extends_list = false\nij_groovy_align_multiline_for = true\nij_groovy_align_multiline_method_parentheses = false\nij_groovy_align_multiline_parameters = true\nij_groovy_align_multiline_parameters_in_calls = false\nij_groovy_align_multiline_resources = true\nij_groovy_align_multiline_ternary_operation = false\nij_groovy_align_multiline_throws_list = false\nij_groovy_align_throws_keyword = false\nij_groovy_array_initializer_new_line_after_left_brace = false\nij_groovy_array_initializer_right_brace_on_new_line = false\nij_groovy_array_initializer_wrap = off\nij_groovy_assert_statement_wrap = off\nij_groovy_assignment_wrap = off\nij_groovy_binary_operation_wrap = off\nij_groovy_blank_lines_after_class_header = 0\nij_groovy_blank_lines_after_imports = 1\nij_groovy_blank_lines_after_package = 1\nij_groovy_blank_lines_around_class = 1\nij_groovy_blank_lines_around_field = 0\nij_groovy_blank_lines_around_field_in_interface = 0\nij_groovy_blank_lines_around_method = 1\nij_groovy_blank_lines_around_method_in_interface = 1\nij_groovy_blank_lines_before_imports = 1\nij_groovy_blank_lines_before_method_body = 0\nij_groovy_blank_lines_before_package = 0\nij_groovy_block_brace_style = end_of_line\nij_groovy_block_comment_at_first_column = true\nij_groovy_call_parameters_new_line_after_left_paren = false\nij_groovy_call_parameters_right_paren_on_new_line = false\nij_groovy_call_parameters_wrap = off\nij_groovy_catch_on_new_line = false\nij_groovy_class_annotation_wrap = split_into_lines\nij_groovy_class_brace_style = end_of_line\nij_groovy_do_while_brace_force = never\nij_groovy_else_on_new_line = false\nij_groovy_enum_constants_wrap = off\nij_groovy_extends_keyword_wrap = off\nij_groovy_extends_list_wrap = off\nij_groovy_field_annotation_wrap = split_into_lines\nij_groovy_finally_on_new_line = false\nij_groovy_for_brace_force = never\nij_groovy_for_statement_new_line_after_left_paren = false\nij_groovy_for_statement_right_paren_on_new_line = false\nij_groovy_for_statement_wrap = off\nij_groovy_if_brace_force = never\nij_groovy_indent_case_from_switch = true\nij_groovy_keep_blank_lines_before_right_brace = 2\nij_groovy_keep_blank_lines_in_code = 2\nij_groovy_keep_blank_lines_in_declarations = 2\nij_groovy_keep_control_statement_in_one_line = true\nij_groovy_keep_first_column_comment = true\nij_groovy_keep_indents_on_empty_lines = false\nij_groovy_keep_line_breaks = true\nij_groovy_keep_multiple_expressions_in_one_line = false\nij_groovy_keep_simple_blocks_in_one_line = false\nij_groovy_keep_simple_classes_in_one_line = true\nij_groovy_keep_simple_lambdas_in_one_line = true\nij_groovy_keep_simple_methods_in_one_line = true\nij_groovy_label_indent_absolute = false\nij_groovy_label_indent_size = 0\nij_groovy_lambda_brace_style = end_of_line\nij_groovy_line_comment_add_space = false\nij_groovy_line_comment_at_first_column = true\nij_groovy_method_annotation_wrap = split_into_lines\nij_groovy_method_brace_style = end_of_line\nij_groovy_method_call_chain_wrap = off\nij_groovy_method_parameters_new_line_after_left_paren = false\nij_groovy_method_parameters_right_paren_on_new_line = false\nij_groovy_method_parameters_wrap = off\nij_groovy_modifier_list_wrap = false\nij_groovy_parameter_annotation_wrap = off\nij_groovy_parentheses_expression_new_line_after_left_paren = false\nij_groovy_parentheses_expression_right_paren_on_new_line = false\nij_groovy_prefer_parameters_wrap = false\nij_groovy_resource_list_new_line_after_left_paren = false\nij_groovy_resource_list_right_paren_on_new_line = false\nij_groovy_resource_list_wrap = off\nij_groovy_space_after_colon = true\nij_groovy_space_after_comma = true\nij_groovy_space_after_comma_in_type_arguments = true\nij_groovy_space_after_for_semicolon = true\nij_groovy_space_after_quest = true\nij_groovy_space_after_type_cast = true\nij_groovy_space_before_annotation_parameter_list = false\nij_groovy_space_before_array_initializer_left_brace = false\nij_groovy_space_before_catch_keyword = true\nij_groovy_space_before_catch_left_brace = true\nij_groovy_space_before_catch_parentheses = true\nij_groovy_space_before_class_left_brace = true\nij_groovy_space_before_colon = true\nij_groovy_space_before_comma = false\nij_groovy_space_before_do_left_brace = true\nij_groovy_space_before_else_keyword = true\nij_groovy_space_before_else_left_brace = true\nij_groovy_space_before_finally_keyword = true\nij_groovy_space_before_finally_left_brace = true\nij_groovy_space_before_for_left_brace = true\nij_groovy_space_before_for_parentheses = true\nij_groovy_space_before_for_semicolon = false\nij_groovy_space_before_if_left_brace = true\nij_groovy_space_before_if_parentheses = true\nij_groovy_space_before_method_call_parentheses = false\nij_groovy_space_before_method_left_brace = true\nij_groovy_space_before_method_parentheses = false\nij_groovy_space_before_quest = true\nij_groovy_space_before_switch_left_brace = true\nij_groovy_space_before_switch_parentheses = true\nij_groovy_space_before_synchronized_left_brace = true\nij_groovy_space_before_synchronized_parentheses = true\nij_groovy_space_before_try_left_brace = true\nij_groovy_space_before_try_parentheses = true\nij_groovy_space_before_while_keyword = true\nij_groovy_space_before_while_left_brace = true\nij_groovy_space_before_while_parentheses = true\nij_groovy_space_within_empty_array_initializer_braces = false\nij_groovy_space_within_empty_method_call_parentheses = false\nij_groovy_spaces_around_additive_operators = true\nij_groovy_spaces_around_assignment_operators = true\nij_groovy_spaces_around_bitwise_operators = true\nij_groovy_spaces_around_equality_operators = true\nij_groovy_spaces_around_lambda_arrow = true\nij_groovy_spaces_around_logical_operators = true\nij_groovy_spaces_around_multiplicative_operators = true\nij_groovy_spaces_around_relational_operators = true\nij_groovy_spaces_around_shift_operators = true\nij_groovy_spaces_within_annotation_parentheses = false\nij_groovy_spaces_within_array_initializer_braces = false\nij_groovy_spaces_within_braces = true\nij_groovy_spaces_within_brackets = false\nij_groovy_spaces_within_cast_parentheses = false\nij_groovy_spaces_within_catch_parentheses = false\nij_groovy_spaces_within_for_parentheses = false\nij_groovy_spaces_within_if_parentheses = false\nij_groovy_spaces_within_method_call_parentheses = false\nij_groovy_spaces_within_method_parentheses = false\nij_groovy_spaces_within_parentheses = false\nij_groovy_spaces_within_switch_parentheses = false\nij_groovy_spaces_within_synchronized_parentheses = false\nij_groovy_spaces_within_try_parentheses = false\nij_groovy_spaces_within_while_parentheses = false\nij_groovy_special_else_if_treatment = true\nij_groovy_ternary_operation_wrap = off\nij_groovy_throws_keyword_wrap = off\nij_groovy_throws_list_wrap = off\nij_groovy_use_relative_indents = false\nij_groovy_variable_annotation_wrap = off\nij_groovy_while_brace_force = never\nij_groovy_while_on_new_line = false\nij_groovy_wrap_long_lines = false\n\n[{*.js,*.cjs}]\nij_javascript_align_imports = false\nij_javascript_align_multiline_array_initializer_expression = false\nij_javascript_align_multiline_binary_operation = false\nij_javascript_align_multiline_chained_methods = false\nij_javascript_align_multiline_extends_list = false\nij_javascript_align_multiline_for = true\nij_javascript_align_multiline_parameters = true\nij_javascript_align_multiline_parameters_in_calls = false\nij_javascript_align_multiline_ternary_operation = false\nij_javascript_align_object_properties = 0\nij_javascript_align_union_types = false\nij_javascript_align_var_statements = 0\nij_javascript_array_initializer_new_line_after_left_brace = false\nij_javascript_array_initializer_right_brace_on_new_line = false\nij_javascript_array_initializer_wrap = off\nij_javascript_assignment_wrap = off\nij_javascript_binary_operation_sign_on_next_line = false\nij_javascript_binary_operation_wrap = off\nij_javascript_blacklist_imports = rxjs/Rx,node_modules/**/*,@angular/material,@angular/material/typings/**,~/node_modules/**/*,@/node_modules/**/*\nij_javascript_blank_lines_after_imports = 1\nij_javascript_blank_lines_around_class = 1\nij_javascript_blank_lines_around_field = 0\nij_javascript_blank_lines_around_function = 1\nij_javascript_blank_lines_around_method = 1\nij_javascript_block_brace_style = next_line\nij_javascript_call_parameters_new_line_after_left_paren = false\nij_javascript_call_parameters_right_paren_on_new_line = false\nij_javascript_call_parameters_wrap = off\nij_javascript_catch_on_new_line = true\nij_javascript_chained_call_dot_on_new_line = true\nij_javascript_class_brace_style = next_line\nij_javascript_comma_on_new_line = false\nij_javascript_do_while_brace_force = always\nij_javascript_else_on_new_line = true\nij_javascript_enforce_trailing_comma = keep\nij_javascript_extends_keyword_wrap = off\nij_javascript_extends_list_wrap = off\nij_javascript_field_prefix = _\nij_javascript_file_name_style = relaxed\nij_javascript_finally_on_new_line = true\nij_javascript_for_brace_force = always\nij_javascript_for_statement_new_line_after_left_paren = false\nij_javascript_for_statement_right_paren_on_new_line = false\nij_javascript_for_statement_wrap = off\nij_javascript_force_quote_style = false\nij_javascript_force_semicolon_style = false\nij_javascript_function_expression_brace_style = next_line\nij_javascript_if_brace_force = always\nij_javascript_import_merge_members = global\nij_javascript_import_prefer_absolute_path = global\nij_javascript_import_sort_members = true\nij_javascript_import_sort_module_name = false\nij_javascript_import_use_node_resolution = true\nij_javascript_imports_wrap = on_every_item\nij_javascript_indent_case_from_switch = true\nij_javascript_indent_chained_calls = true\nij_javascript_indent_package_children = 0\nij_javascript_jsx_attribute_value = braces\nij_javascript_keep_blank_lines_in_code = 2\nij_javascript_keep_first_column_comment = true\nij_javascript_keep_indents_on_empty_lines = false\nij_javascript_keep_line_breaks = true\nij_javascript_keep_simple_blocks_in_one_line = false\nij_javascript_keep_simple_methods_in_one_line = false\nij_javascript_line_comment_add_space = true\nij_javascript_line_comment_at_first_column = false\nij_javascript_method_brace_style = next_line\nij_javascript_method_call_chain_wrap = off\nij_javascript_method_parameters_new_line_after_left_paren = false\nij_javascript_method_parameters_right_paren_on_new_line = false\nij_javascript_method_parameters_wrap = off\nij_javascript_object_literal_wrap = on_every_item\nij_javascript_parentheses_expression_new_line_after_left_paren = false\nij_javascript_parentheses_expression_right_paren_on_new_line = false\nij_javascript_place_assignment_sign_on_next_line = false\nij_javascript_prefer_as_type_cast = false\nij_javascript_prefer_parameters_wrap = false\nij_javascript_reformat_c_style_comments = false\nij_javascript_space_after_colon = true\nij_javascript_space_after_comma = true\nij_javascript_space_after_dots_in_rest_parameter = false\nij_javascript_space_after_generator_mult = true\nij_javascript_space_after_property_colon = true\nij_javascript_space_after_quest = true\nij_javascript_space_after_type_colon = true\nij_javascript_space_after_unary_not = false\nij_javascript_space_before_async_arrow_lparen = true\nij_javascript_space_before_catch_keyword = true\nij_javascript_space_before_catch_left_brace = true\nij_javascript_space_before_catch_parentheses = true\nij_javascript_space_before_class_lbrace = true\nij_javascript_space_before_class_left_brace = true\nij_javascript_space_before_colon = true\nij_javascript_space_before_comma = false\nij_javascript_space_before_do_left_brace = true\nij_javascript_space_before_else_keyword = true\nij_javascript_space_before_else_left_brace = true\nij_javascript_space_before_finally_keyword = true\nij_javascript_space_before_finally_left_brace = true\nij_javascript_space_before_for_left_brace = true\nij_javascript_space_before_for_parentheses = true\nij_javascript_space_before_for_semicolon = false\nij_javascript_space_before_function_left_parenth = true\nij_javascript_space_before_generator_mult = false\nij_javascript_space_before_if_left_brace = true\nij_javascript_space_before_if_parentheses = true\nij_javascript_space_before_method_call_parentheses = false\nij_javascript_space_before_method_left_brace = true\nij_javascript_space_before_method_parentheses = false\nij_javascript_space_before_property_colon = false\nij_javascript_space_before_quest = true\nij_javascript_space_before_switch_left_brace = true\nij_javascript_space_before_switch_parentheses = true\nij_javascript_space_before_try_left_brace = true\nij_javascript_space_before_type_colon = false\nij_javascript_space_before_unary_not = false\nij_javascript_space_before_while_keyword = true\nij_javascript_space_before_while_left_brace = true\nij_javascript_space_before_while_parentheses = true\nij_javascript_spaces_around_additive_operators = true\nij_javascript_spaces_around_arrow_function_operator = true\nij_javascript_spaces_around_assignment_operators = true\nij_javascript_spaces_around_bitwise_operators = true\nij_javascript_spaces_around_equality_operators = true\nij_javascript_spaces_around_logical_operators = true\nij_javascript_spaces_around_multiplicative_operators = true\nij_javascript_spaces_around_relational_operators = true\nij_javascript_spaces_around_shift_operators = true\nij_javascript_spaces_around_unary_operator = false\nij_javascript_spaces_within_array_initializer_brackets = false\nij_javascript_spaces_within_brackets = false\nij_javascript_spaces_within_catch_parentheses = false\nij_javascript_spaces_within_for_parentheses = false\nij_javascript_spaces_within_if_parentheses = false\nij_javascript_spaces_within_imports = false\nij_javascript_spaces_within_interpolation_expressions = false\nij_javascript_spaces_within_method_call_parentheses = false\nij_javascript_spaces_within_method_parentheses = false\nij_javascript_spaces_within_object_literal_braces = false\nij_javascript_spaces_within_object_type_braces = true\nij_javascript_spaces_within_parentheses = false\nij_javascript_spaces_within_switch_parentheses = false\nij_javascript_spaces_within_type_assertion = false\nij_javascript_spaces_within_union_types = true\nij_javascript_spaces_within_while_parentheses = false\nij_javascript_special_else_if_treatment = true\nij_javascript_ternary_operation_signs_on_next_line = false\nij_javascript_ternary_operation_wrap = off\nij_javascript_union_types_wrap = on_every_item\nij_javascript_use_chained_calls_group_indents = false\nij_javascript_use_double_quotes = true\nij_javascript_use_path_mapping = always\nij_javascript_use_public_modifier = false\nij_javascript_use_semicolon_after_statement = true\nij_javascript_var_declaration_wrap = normal\nij_javascript_while_brace_force = always\nij_javascript_while_on_new_line = true\nij_javascript_wrap_comments = false\n\n[{*.ng,*.sht,*.html,*.shtm,*.shtml,*.htm}]\nindent_style = space\nij_smart_tabs = false\nij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3\nij_html_align_attributes = true\nij_html_align_text = false\nij_html_attribute_wrap = normal\nij_html_block_comment_at_first_column = true\nij_html_do_not_align_children_of_min_lines = 0\nij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p\nij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot\nij_html_enforce_quotes = false\nij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var\nij_html_keep_blank_lines = 2\nij_html_keep_indents_on_empty_lines = false\nij_html_keep_line_breaks = true\nij_html_keep_line_breaks_in_text = true\nij_html_keep_whitespaces = false\nij_html_keep_whitespaces_inside = span,pre,textarea\nij_html_line_comment_at_first_column = true\nij_html_new_line_after_last_attribute = never\nij_html_new_line_before_first_attribute = never\nij_html_quote_style = double\nij_html_remove_new_line_before_tags = br\nij_html_space_after_tag_name = false\nij_html_space_around_equality_in_attribute = false\nij_html_space_inside_empty_tag = false\nij_html_text_wrap = normal\n\n[{*.yml,*.yaml}]\nij_yaml_keep_indents_on_empty_lines = false\nij_yaml_keep_line_breaks = true\nindent_style = space\nindent_size = 2\n\n[{.eslintrc,.babelrc,composer.lock,.stylelintrc,jest.config,bowerrc,*.json,*.jsb3,*.jsb2}]\nindent_style = space\nij_smart_tabs = false\nij_json_keep_blank_lines_in_code = 0\nij_json_keep_indents_on_empty_lines = false\nij_json_keep_line_breaks = true\nij_json_space_after_colon = true\nij_json_space_after_comma = true\nij_json_space_before_colon = true\nij_json_space_before_comma = false\nij_json_spaces_within_braces = false\nij_json_spaces_within_brackets = false\nij_json_wrap_long_lines = false\n\n[{phpunit.xml.dist,*.xslt,*.xul,*.rng,*.xsl,*.xsd,*.ant,*.wadl,*.jhm,*.tld,*.fxml,*.jrxml,*.xml,*.jnlp,*.wsdl,*.wsdd,*.xjb}]\nindent_style = space\nij_smart_tabs = false\nij_xml_block_comment_at_first_column = true\nij_xml_keep_indents_on_empty_lines = false\nij_xml_line_comment_at_first_column = true\nij_xml_space_inside_empty_tag = false\n\n[{spring.schemas,spring.handlers,*.properties}]\nij_properties_align_group_field_declarations = false"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: Bug report\ndescription: Something doesn't work properly and you want it fixed.\nlabels: [ bug ]\nbody:\n- type: markdown\n  attributes:\n    value: |\n      Thanks for taking the time to fill out this bug report.\n- type: textarea\n  id: what-happened\n  attributes:\n    label: What happened?\n    description: Also tell us, what did you expect to happen.\n    placeholder: Tell us what you see!\n  validations:\n    required: true\n- type: textarea\n  id: how-to-reproduce\n  attributes:\n    label: How to reproduce\n    description: List all steps as precisely as possible\n    placeholder: |\n      1. Go to menu XYZ\n      2. Select option A\n      3. etc...\n  validations:\n    required: true\n- type: input\n  id: version\n  attributes:\n    label: Version\n    description: Which version of Xeres are you running? See Help/About menu.\n    placeholder: ex. 0.2.0\n  validations:\n    required: false\n- type: dropdown\n  id: os\n  attributes:\n    label: Which OS are you running?\n    options:\n    - Windows\n    - Linux\n    - MacOS\n  validations:\n    required: true\n- type: textarea\n  id: logs\n  attributes:\n    label: Relevant log output\n    description: |\n      Please paste any relevant log output here, if you have them. \n      If you have an error requester, click the clipboard icon and paste the output here. \n      Otherwise the logs location are:\n      - Windows: %APPDATA%\\Xeres\\Logs\\xeres.log\n      - Linux: $HOME/.local/share/Xeres/Logs/xeres.log\n    render: shell\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "name: Feature request\ndescription: You have some cool idea or suggestion for improvement.\nlabels: [ feature ]\nbody:\n- type: markdown\n  attributes:\n    value: |\n      Thanks for your interest in improving Xeres.\n- type: textarea\n  id: feature\n  attributes:\n    label: Description\n    description: Your idea or improvement\n  validations:\n    required: true"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: \"gradle\"\n  directory: \"/\"\n  schedule:\n    interval: \"daily\"\n  ignore:\n    - dependency-name: \"org.flywaydb.flyway\"\n\n- package-ecosystem: \"github-actions\"\n  directory: \"/\"\n  schedule:\n    interval: \"daily\"\n\n- package-ecosystem: \"pip\"\n  directory: \"/scripts/bot\"\n  schedule:\n    interval: \"daily\""
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "Describe the change here\n\nFixes #issue_number"
  },
  {
    "path": ".github/workflows/analysis.yml",
    "content": "name: \"Analysis\"\n\non:\n  push:\n    branches:\n    - master\n  pull_request:\n    branches:\n      - master\n    types:\n      - opened\n      - synchronize\n      - reopened\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read # CodeQL\n      contents: read # CodeQL\n      security-events: write # CodeQL\n      pull-requests: read # Sonarqube\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0 # Disable shallow clone for sonarqube analysis\n\n      - name: Check gradle wrapper\n        uses: gradle/actions/wrapper-validation@v6\n\n      - name: Set up JDK\n        uses: actions/setup-java@v5\n        with:\n          java-version: 25.0.3\n          distribution: 'graalvm'\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n        with:\n          languages: java\n          queries: security-and-quality\n\n      - name: Cache SonarCloud packages\n        uses: actions/cache@v5\n        with:\n          path: ~/.sonar/cache\n          key: ${{ runner.os }}-sonar\n          restore-keys: ${{ runner.os }}-sonar\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v6\n\n      - name: Build, test and generate reports\n        run: ./gradlew build test jacocoTestReport --no-build-cache --info # Disabling the build cache is needed for CodeQL (otherwise compilation output might not be generated)\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n\n      - name: Perform Sonarqube Analysis\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any\n          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}\n        run: ./gradlew sonar --no-parallel --no-configuration-cache # The 2 flags make sonar work with Gradle 9. Go figure...\n"
  },
  {
    "path": ".github/workflows/build-docker.yml",
    "content": "# Docker image builder\n\nname: build-docker.yml\n\non:\n  workflow_dispatch:\n\nenv:\n  JAVA_VERSION: '25.0.3'\n  JAVA_DISTRIBUTION: 'graalvm'\n\njobs:\n  build-docker-release:\n    name: Build ${{ matrix.arch }} release\n    runs-on: ${{ matrix.runner }}\n    strategy:\n      matrix:\n        include:\n          - arch: amd64\n            runner: ubuntu-24.04\n          - arch: arm64\n            runner: ubuntu-24.04-arm\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Get version from git tag\n        id: get_version\n        run: echo \"VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')\" >> $GITHUB_OUTPUT\n\n      - name: Validate Gradle\n        uses: gradle/actions/wrapper-validation@v6\n\n      - name: Setup JDK\n        uses: graalvm/setup-graalvm@v1\n        with:\n          java-version: ${{ env.JAVA_VERSION }}\n          distribution: ${{ env.JAVA_DISTRIBUTION }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v6\n\n      - name: Build Docker image\n        run: ./gradlew bootBuildImage --imageName=${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-${{ matrix.arch }}\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Push Docker image\n        run: |\n          docker push ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-${{ matrix.arch }}\n\n  create-manifest:\n    needs: build-docker-release\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Get version from git tag\n        id: get_version\n        run: |\n          git fetch --tags\n          echo \"VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')\" >> $GITHUB_OUTPUT\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Create and push manifest\n        run: |\n          docker manifest create ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }} \\\n            ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-amd64 \\\n            ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-arm64\n\n          docker manifest create ${{ secrets.DOCKERHUB_USERNAME }}/xeres:latest \\\n            ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-amd64 \\\n            ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}-arm64\n\n          docker manifest push ${{ secrets.DOCKERHUB_USERNAME }}/xeres:${{ steps.get_version.outputs.VERSION }}\n          docker manifest push ${{ secrets.DOCKERHUB_USERNAME }}/xeres:latest"
  },
  {
    "path": ".github/workflows/build-installer.yml",
    "content": "# This is the installer builder.\n#\n# It's triggered automatically when a version tag is pushed.\n#\n# For manual builds, add automatic_release_tag: \"Nightly\", otherwise the release creation will fail.\n# The previous steps will work though so this can be used to check that it builds the installers.\n# fetch-depth has been set to 0 so that manual builds will work, otherwise it might not find the version tag\n# as it only fetches the latest commit history by default.\n#\n# When setting the java version, always use x.y.z even if there's more numbers, otherwise @setup-java will fail.\n#\n\nname: Installer build\n\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - \"v*\"\n\nenv:\n  JAVA_VERSION: '25.0.3'\n  JAVA_DISTRIBUTION: 'graalvm'\n\njobs:\n  build-windows-installer-msi:\n    name: Build windows installer\n    runs-on: windows-2025\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Validate Gradle\n        uses: gradle/actions/wrapper-validation@v6\n\n      - name: Setup JDK\n        uses: graalvm/setup-graalvm@v1\n        with:\n          java-version: ${{ env.JAVA_VERSION }}\n          distribution: ${{ env.JAVA_DISTRIBUTION }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v6\n\n      - name: Build\n        run: .\\gradlew.bat jpackage\n\n      - name: Sign\n        uses: dlemstra/code-sign-action@v1\n        with:\n          certificate: '${{ secrets.CERTIFICATE }}'\n          files: |\n            ./app/build/distributions/Xeres-*.msi\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v7\n        with:\n          path: ./app/build/distributions/Xeres-*.msi\n          name: windows-installer-msi\n          retention-days: 1\n\n  build-windows-installer-portable:\n    name: Build windows portable installer\n    runs-on: windows-2025\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Validate Gradle\n        uses: gradle/actions/wrapper-validation@v6\n\n      - name: Setup JDK\n        uses: graalvm/setup-graalvm@v1\n        with:\n          java-version: ${{ env.JAVA_VERSION }}\n          distribution: ${{ env.JAVA_DISTRIBUTION }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v6\n\n      - name: Build\n        run: .\\gradlew.bat jpackage -P\"jpackage.portable=true\"\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v7\n        with:\n          path: ./app/build/distributions/Xeres-*.zip\n          name: windows-installer-portable\n          retention-days: 1\n\n  build-macos-installer:\n    name: Build ${{ matrix.macos-version }} installer\n    runs-on: ${{ matrix.macos-version }}\n    strategy:\n      matrix:\n        macos-version: [ macos-26 ]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Validate Gradle\n        uses: gradle/actions/wrapper-validation@v6\n\n      - name: Setup JDK\n        uses: graalvm/setup-graalvm@v1\n        with:\n          java-version: ${{ env.JAVA_VERSION }}\n          distribution: ${{ env.JAVA_DISTRIBUTION }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v6\n\n      - name: Build\n        run: ./gradlew jpackage\n\n      - name: Rename .dmg file\n        run: |\n          # Extract filename\n          DMG_FILE=$(find ./app/build/distributions -name \"*.dmg\" -type f | head -n 1)\n          FILENAME=$(basename \"$DMG_FILE\" .dmg)\n          \n          # Split into components\n          NAME_PART=$(echo \"$FILENAME\" | cut -d'-' -f1)        # xeres\n          VERSION_PART=$(echo \"$FILENAME\" | cut -d'-' -f2)     # x.y.z\n          \n          # Create new filename\n          NEW_FILENAME=\"${NAME_PART}-${VERSION_PART}-${{ matrix.macos-version }}.dmg\"\n          \n          # Rename the file         \n          mv \"./app/build/distributions/${FILENAME}.dmg\" \"./app/build/distributions/${NEW_FILENAME}\"\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v7\n        with:\n          path: ./app/build/distributions/Xeres-*.dmg\n          name: macos-installer-${{ matrix.macos-version }}\n          retention-days: 1\n\n  build-ubuntu-installer-deb:\n    name: Build ${{ matrix.ubuntu-version }} installer\n    runs-on: ${{ matrix.ubuntu-version }}\n    strategy:\n      matrix:\n        ubuntu-version: [ ubuntu-22.04, ubuntu-22.04-arm, ubuntu-24.04, ubuntu-24.04-arm ]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Validate Gradle\n        uses: gradle/actions/wrapper-validation@v6\n\n      - name: Setup JDK\n        uses: graalvm/setup-graalvm@v1\n        with:\n          java-version: ${{ env.JAVA_VERSION }}\n          distribution: ${{ env.JAVA_DISTRIBUTION }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v6\n\n      - name: Build\n        run: ./gradlew jpackage\n\n      - name: Rename .deb file\n        run: |\n          # Extract filename\n          DEB_FILE=$(find ./app/build/distributions -name \"*.deb\" -type f | head -n 1)\n          FILENAME=$(basename \"$DEB_FILE\" .deb)\n          \n          # Split into components\n          NAME_PART=$(echo \"$FILENAME\" | cut -d'_' -f1)        # xeres\n          VERSION_PART=$(echo \"$FILENAME\" | cut -d'_' -f2)     # x.y.z\n          ARCH_PART=$(echo \"$FILENAME\" | cut -d'_' -f3-)       # amd64\n          \n          # Make sure we don't include the '-arm' ending as it's redundant\n          UBUNTU_VERSION=$(echo \"${{ matrix.ubuntu-version }}\" | sed 's/-arm$//')\n          \n          # Create new filename\n          NEW_FILENAME=\"${NAME_PART}_${VERSION_PART}_${UBUNTU_VERSION}_${ARCH_PART}.deb\"\n          \n          # Rename the file         \n          mv \"./app/build/distributions/${FILENAME}.deb\" \"./app/build/distributions/${NEW_FILENAME}\"\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v7\n        with:\n          path: ./app/build/distributions/xeres_*.deb\n          name: ubuntu-installer-deb-${{ matrix.ubuntu-version }}\n          retention-days: 1\n\n  create-release:\n    name: Create release\n    runs-on: ubuntu-latest\n    needs: [ build-windows-installer-msi,\n             build-windows-installer-portable,\n             build-ubuntu-installer-deb,\n             build-macos-installer ]\n    steps:\n      - name: Download windows installer\n        uses: actions/download-artifact@v8\n        with:\n          name: windows-installer-msi\n\n      - name: Download windows portable installer\n        uses: actions/download-artifact@v8\n        with:\n          name: windows-installer-portable\n\n      - name: Download ubuntu-22.04 installer\n        uses: actions/download-artifact@v8\n        with:\n          name: ubuntu-installer-deb-ubuntu-22.04\n\n      - name: Download ubuntu-24.04 installer\n        uses: actions/download-artifact@v8\n        with:\n          name: ubuntu-installer-deb-ubuntu-24.04\n\n      - name: Download ubuntu-22.04-arm installer\n        uses: actions/download-artifact@v8\n        with:\n          name: ubuntu-installer-deb-ubuntu-22.04-arm\n\n      - name: Download ubuntu-24.04-arm installer\n        uses: actions/download-artifact@v8\n        with:\n          name: ubuntu-installer-deb-ubuntu-24.04-arm\n\n      # ARM\n      - name: Download macos-26 installer\n        uses: actions/download-artifact@v8\n        with:\n          name: macos-installer-macos-26\n\n      - name: Generate checksum\n        uses: jmgilman/actions-generate-checksum@v1\n        with:\n          patterns: |\n            *.exe\n            *.msi\n            *.deb\n            *.rpm\n            *.dmg\n            *.zip\n\n      - name: Create Github release\n        uses: marvinpinto/action-automatic-releases@v1.2.1\n        with:\n          repo_token: \"${{ secrets.GITHUB_TOKEN }}\"\n          prerelease: false\n          draft: true\n          files: |\n            checksum.txt\n            *.exe\n            *.msi\n            *.deb\n            *.rpm\n            *.dmg\n            *.zip\n\n"
  },
  {
    "path": ".github/workflows/dependencies.yaml",
    "content": "name: \"Gradle dependencies\"\n\non:\n  push:\n    branches:\n      - master\n\njobs:\n  build:\n    name: Dependencies\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write # Dependency Submission API\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0 # A tag is needed for git version generation to work\n\n      - name: Setup JDK\n        uses: actions/setup-java@v5\n        with:\n          java-version: 25.0.3\n          distribution: 'graalvm'\n\n      - name: Generate and submit dependency graph\n        uses: gradle/actions/dependency-submission@v6\n"
  },
  {
    "path": ".github/workflows/qodana_code_quality.yml",
    "content": "#-------------------------------------------------------------------------------#\n#        Discover additional configuration options in our documentation         #\n#               https://www.jetbrains.com/help/qodana/github.html               #\n#-------------------------------------------------------------------------------#\n\nname: Qodana\non:\n  workflow_dispatch:\n  pull_request:\n  push:\n    branches:\n      - master\n\njobs:\n  qodana:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n      checks: write\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n          fetch-depth: 0\n      - name: 'Qodana Scan'\n        uses: JetBrains/qodana-action@v2026.1.0\n        env:\n          QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}\n        with:\n          # When pr-mode is set to true, Qodana analyzes only the files that have been changed\n          pr-mode: false\n          use-caches: true\n          post-pr-comment: true\n          use-annotations: true\n          # Upload Qodana results (SARIF, other artifacts, logs) as an artifact to the job\n          upload-result: false\n          # quick-fixes available in Ultimate and Ultimate Plus plans\n          push-fixes: 'none'"
  },
  {
    "path": ".gitignore",
    "content": "/data*/\n/.idea/\n/.vscode/\n/.gradle/\n/.venv/\n./jpb/\n/*/build/\n/build/\n/*/out/\n/*/bin/\n.xeres.lock\n/.jpb/persistence-units.xml\n.run/XeresApplication.run.xml\n/scripts/bot/config.json\n/scripts/bot/avatar.png\n/cache/\n/.proxyai/\n/.project\n/*/.project\n"
  },
  {
    "path": ".run/All Tests.run.xml",
    "content": "<component name=\"ProjectRunConfigurationManager\">\n    <configuration default=\"false\" name=\"All Tests\" type=\"GradleRunConfiguration\" factoryName=\"Gradle\">\n        <ExternalSystemSettings>\n            <option name=\"executionName\"/>\n            <option name=\"externalProjectPath\" value=\"$PROJECT_DIR$\"/>\n            <option name=\"externalSystemIdString\" value=\"GRADLE\"/>\n            <option name=\"scriptParameters\" value=\"\"/>\n            <option name=\"taskDescriptions\">\n                <list/>\n            </option>\n            <option name=\"taskNames\">\n                <list>\n                    <option value=\"test\"/>\n                </list>\n            </option>\n            <option name=\"vmOptions\"/>\n        </ExternalSystemSettings>\n        <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>\n        <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>\n        <DebugAllEnabled>false</DebugAllEnabled>\n        <RunAsTest>false</RunAsTest>\n        <method v=\"2\"/>\n    </configuration>\n</component>"
  },
  {
    "path": "AGENTS.md",
    "content": "# Xeres Development Guidelines\n\n## Project Overview\n\nXeres is a Friend-to-Friend, decentralized, and secure communication application. It's a Gradle-based Java project with three subprojects:\n\n- **app**: Main Spring Boot application with business logic\n- **ui**: JavaFX desktop UI\n- **common**: Shared code used by both app and ui\n\n## Build Commands\n\n```bash\n# Run the application\n./gradlew bootRun\n\n# Build without tests\n./gradlew build -x test\n\n# Run tests\n./gradlew test\n\n# Run tests with UI (if applicable)\n./gradlew :ui:test\n\n# Package the application (creates MSI on Windows, AppImage on Linux)\n./gradlew :app:jpackage\n\n# Create portable zip\n./gradlew :app:jpackage -Pjpackage.portable=true\n\n# Clean build\n./gradlew clean\n\n# Build Docker image\n./gradlew :app:bootBuildImage\n```\n\n## Architecture\n\n- Java 25\n- Spring Boot 4.0.5\n- JavaFX 26 (UI module)\n- JUnit 6 for testing\n- ArchUnit for architecture testing\n- Jacoco for code coverage\n- H2 database with Flyway migrations\n- JCA/JCE and BouncyCastle for cryptography\n\n## Code Conventions\n\n- Follow existing code style (enforced by .editorconfig, Allman Style)\n- Use GPL v3 license header on new files\n- Branch naming: `feature/<issue-number>-description` or `bugfix/<issue-number>-description`\n- Package structure: `io.xeres.<module>.<feature>`\n\n## Key Directories\n\n```\napp/src/main/java/io/xeres/app/       - Application entry point and services\nui/src/main/java/io/xeres/ui/         - JavaFX controllers and views\ncommon/src/main/java/io/xeres/common/  - Shared models and utilities\napp/src/main/resources/db/migration/   - Flyway database migrations\n```\n\n## Testing\n\n- Unit tests use JUnit 6 with Jupiter\n- UI tests use TestFX\n- Architecture rules are enforced via ArchUnit in `common/src/test/` and `common/src/testFixtures/`\n\n## Dependencies\n\n- Never modify versions directly; update in `build.gradle` root version properties\n- Keep Spring Boot BOM and related dependencies in sync\n\n## Skills\n\nFor agents, there's a list of skills in `.agents/skills`.\n\n## Source code file format\n\nWhen you create code, use UTF-8 and LF as end of lines, on all architectures.\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "No trolling."
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to contribute\n\nContributions are welcome! Please read the following in order to make it easier.\n\n## Reporting a bug\n\n* use the [issue tracker](https://github.com/zapek/Xeres/issues)\n* make sure the bug is not already in the list, to avoid duplicates (a simple search should do)\n* if in doubt, feel free to [discuss it](https://github.com/zapek/Xeres/discussions) first\n\n## Making a feature request\n\n* use the [issue tracker](https://github.com/zapek/Xeres/issues)\n* make sure the feature request is not already in the list, to avoid duplicates (a simple search should do)\n* if in doubt, feel free to [discuss it](https://github.com/zapek/Xeres/discussions) first\n\n## Submitting changes\n\n* make sure you use the same formatting and style (there's an .editorconfig file that does it automatically)\n* create a branch using either `feature`/name if it's for adding a feature or `bugfix`/name for **simple** bugfixes (otherwise use `feature`). Use a meaningful name like `25-add-multiple-locations` (the first number being the number of the corresponding issue, if any)\n* write a meaningful commit message, [this link](https://chris.beams.io/posts/git-commit/) contains useful information on how to do it\n* create a [pull request](https://github.com/zapek/Xeres/pulls)\n* if in doubt, feel free to [discuss it](https://github.com/zapek/Xeres/discussions) first\n\nThank you!"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<http://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<http://www.gnu.org/philosophy/why-not-lgpl.html>."
  },
  {
    "path": "README.md",
    "content": "[![Main site](docs/logo.png)](https://xeres.io)\n\n[![GitHub release](https://img.shields.io/github/release/zapek/Xeres.svg?label=latest%20release)](https://github.com/zapek/Xeres/releases/latest)\n[![Downloads](https://img.shields.io/github/downloads/zapek/Xeres/total)](https://github.com/zapek/Xeres/releases/latest)\n[![License](https://img.shields.io/github/license/zapek/Xeres.svg?logo=gnu)](https://github.com/zapek/Xeres/blob/master/LICENSE)\n[![CodeQL](https://github.com/zapek/Xeres/actions/workflows/analysis.yml/badge.svg)](https://github.com/zapek/Xeres/actions/workflows/analysis.yml)\n[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=zapek_Xeres&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=zapek_Xeres)\n[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9469/badge)](https://www.bestpractices.dev/projects/9469)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zapek/Xeres)\n\n[Xeres](https://xeres.io) is a Friend-to-Friend, decentralized and secure application for communication and sharing.\n\n![Xeres Desktop](docs/screenshot-chat.jpg)\n\n[More screenshots](https://xeres.io/screenshots/)\n\n---\n\n## Features\n\n- 🤝 Peer-to-Peer ([Friend-to-Friend](https://en.wikipedia.org/wiki/Friend-to-friend)), fully decentralized\n- 🚫 No censorship\n- 💬 Chat directly with your friends or in chat rooms\n- 📢 Participate in forums and discuss any topic\n- 📺 Publish files and news in channels\n- 🖼️ Share pictures and links in boards\n- 📞 Make voice calls with your friends\n- 📂 Share and search files anonymously\n- 👋 Compatible with [Retroshare](https://retroshare.cc) 0.6.6 or higher\n- 🛠️ Hardware accelerated encryption\n- 🖥️ Modern looking GPU-accelerated desktop user interface with several themes including dark mode\n- 📶 Remote access, access your instance on the go (Android mobile client available [here](https://github.com/zapek/Xeres-Android))\n- 📖 Free software ([GPL](https://www.gnu.org/licenses/quick-guide-gplv3.html))\n- 😃 Available for Windows, Linux and macOS\n\n## Releases\n\n[Latest release](https://github.com/zapek/Xeres/releases/latest)\n\n[Android mobile client](https://github.com/zapek/Xeres-Android)\n\n[Docker image](https://hub.docker.com/r/zapek/xeres) (for headless installations)\n\n## Quick try\n\nInstall Xeres then connect to a [ChatServer](https://retroshare.ch).\n\n## Running from source\n\n[Install JDK 25](https://github.com/zapek/Xeres/wiki/Java) then type `./gradlew bootRun`\n\n## Getting Help\n\n- [User Documentation & FAQ](https://xeres.io/docs/)\n- [Discussions & Forums](https://github.com/zapek/Xeres/discussions)\n- [Issues Reporting](https://github.com/zapek/Xeres/issues)\n\n## Documentation\n\n- [Technical Documentation](https://github.com/zapek/Xeres/wiki)\n- [Roadmap](https://github.com/users/zapek/projects/4)\n\n## Development\n\n- [Development Help](https://github.com/zapek/Xeres/wiki#development)\n- [Contributing](CONTRIBUTING.md)\n\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nOnly the latest version is supported. If a security bug is found, a new version will be released as quickly as possible. If there's too much work remaining in the pipeline for the next version, a patch will be released (for example, 0.3.1 -> 0.3.2 while the next version will be 0.3.3).\n\n## Reporting a Vulnerability\n\nEither [fill an issue on GitHub](https://github.com/zapek/Xeres/issues/new?assignees=zapek&labels=bug,security&template=bug_report.yaml) if you believe the issue can be public. If you prefer to keep it quiet, use the contact form [here](https://zapek.com/contact/). You should get a reply quickly (from a few hours for\nup to 48 hours).\n\nIf the vulnerability is accepted, it will be patched as soon as possible and a new release will be made if it is present in the latest release. You will be credited in the changelog.\n\nIf the vulnerability is declined, you'll get an explanation of why.\n"
  },
  {
    "path": "SandBox.wsb",
    "content": "<Configuration>\n<vGPU>disable</vGPU>\n<Networking>Default</Networking>\n<MappedFolders>\n    <MappedFolder>\n        <HostFolder>C:\\Users\\zapek\\workspace\\Xeres\\app\\build\\distributions</HostFolder>\n        <ReadOnly>true</ReadOnly>\n    </MappedFolder>\n</MappedFolders>\n<LogonCommand>\n    <Command>powershell -Command \"Start-Process PowerShell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -Command Set-ItemProperty -Path HKLM:\\SYSTEM\\CurrentControlSet\\Control\\CI\\Policy -Name VerifiedAndReputablePolicyState -Value 0; CiTool.exe -r' -Verb RunAs\"</Command>\n    <!-- <Command>msiexec /i C:\\Users\\WDAGutilityAccount\\Desktop\\distributions\\Xeres-0.8.1.msi</Command>-->\n</LogonCommand>\n</Configuration>"
  },
  {
    "path": "app/build.gradle",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nimport org.panteleyev.jpackage.ImageType\nimport org.springframework.boot.gradle.plugin.SpringBootPlugin\n\n\nplugins {\n    id 'org.springframework.boot'\n    id 'org.flywaydb.flyway'\n    id 'org.panteleyev.jpackageplugin'\n    id 'com.bakdata.mockito'\n}\n\nflyway {\n    url = \"jdbc:h2:file:${project.rootDir}/data/userdata\"\n    user = 'sa'\n}\n\ntasks.register('cleanLibs', Delete) {\n    delete layout.buildDirectory.dir(\"libs\")\n}\n\nbootJar {\n    dependsOn cleanLibs\n    manifest {\n        attributes 'Implementation-Version': \"${project.version}\"\n        attributes 'Implementation-Title': \"${project.name}\"\n    }\n}\n\nbootRun {\n    bootRun.jvmArgs \"-ea\", \"-Djava.net.preferIPv4Stack=true\", \"-Dfile.encoding=UTF-8\"\n    bootRun.systemProperty 'spring.profiles.active', 'dev'\n}\n\nspringBoot {\n    buildInfo {\n        excludes = ['time'] // make the build repeatable\n        properties {\n            name = rootProject.name\n        }\n    }\n}\n\ntest {\n    useJUnitPlatform()\n    test.jvmArgs \"-ea\", \"-Djava.net.preferIPv4Stack=true\", \"-Dfile.encoding=UTF-8\"\n}\n\ntasks.register('copyInstaller', Copy) {\n    dependsOn cleanLibs\n    from layout.settingsDirectory.dir(\"installer\")\n    into layout.buildDirectory.dir(\"libs\")\n}\n\nbootBuildImage {\n    // Don't forget to set the image platform, for example: -Dimage.platform=linux-x86_64 or -Dimage.platform=linux-aarch_64\n    imageName = \"zapek/${rootProject.name.toLowerCase(Locale.ROOT)}:${project.version}\"\n}\n\ntasks.register('deletePortable', Delete) {\n    delete layout.buildDirectory.dir(\"distributions\").get().dir(rootProject.name)\n}\n\ntasks.register('createPortableFile') {\n    notCompatibleWithConfigurationCache(\"Uses execution-time configuration filtering\")\n    doLast {\n        def portable = new File(\"${layout.buildDirectory.dir(\"distributions\").get().dir(rootProject.name)}\", \"Portable\")\n        if (!portable.getParentFile().exists()) {\n            portable.getParentFile().mkdirs()\n        }\n        portable.createNewFile()\n    }\n}\n\ntasks.register('packagePortable', Zip) {\n    dependsOn createPortableFile\n    finalizedBy deletePortable\n    archiveFileName = \"${rootProject.name}-${project.version}-portable.zip\"\n    destinationDirectory = layout.buildDirectory.dir(\"distributions\")\n    from layout.buildDirectory.dir(\"distributions\").get().dir(rootProject.name)\n}\n\njpackage {\n    dependsOn bootJar, copyInstaller\n    finalizedBy packagePortable\n    appName = parent.project.name\n    appVersion = \"${project.version}\".split(\"-\")[0]\n    vendor = \"David Gerber\"\n    copyright = \"Copyright 2019-2026 by David Gerber. All Rights Reserved\"\n    appDescription = parent.project.name\n    input = layout.buildDirectory.dir(\"libs\")\n    destination = layout.buildDirectory.dir(\"distributions\")\n    mainJar = tasks.bootJar.archiveFileName.get()\n    if (project.hasProperty(\"jpackage.portable\")) {\n        type = ImageType.APP_IMAGE\n    } else {\n        licenseFile = layout.settingsDirectory.file(\"LICENSE\")\n        aboutUrl = \"https://xeres.io\"\n    }\n    // Do not supply files relative to currentDir. It can change depending on how the executable is started\n    javaOptions = ['-Djava.net.preferIPv4Stack=true',\n                   '-Dfile.encoding=UTF-8',\n                   '-splash:$APPDIR/startup.png',\n                   '-Xms256m',\n                   '-Xmx1g',\n                   '-XX:+UseZGC',\n                   '-XX:MaxMetaspaceSize=256m',\n                   '-XX:+UseCompactObjectHeaders',\n                   '-Dvisualvm.display.name=Xeres',\n                   '-Dlogging.logback.rollingpolicy.clean-history-on-start=true',\n                   '-Dlogging.logback.rollingpolicy.max-file-size=20MB',\n                   '-Dlogging.logback.rollingpolicy.max-history=3',\n                   '-Dspring.output.ansi.enabled=never']\n    windows {\n        if (!project.hasProperty(\"jpackage.portable\")) {\n            type = ImageType.MSI\n            winMenu = true\n            winPerUserInstall = true\n            winDirChooser = true\n            winMenuGroup = parent.project.name\n            winUpgradeUuid = \"97a4aaa5-0a3f-47f9-b0a2-f91876d9e7dd\"\n        }\n        icon = layout.settingsDirectory.file(\"icon.ico\")\n    }\n    linux {\n        if (project.hasProperty(\"jpackage.rpm\")) {\n            type = ImageType.RPM\n        }\n        linuxShortcut = true\n        icon = layout.settingsDirectory.file(\"icon.png\")\n    }\n    mac {\n        icon = layout.settingsDirectory.file(\"icon.icns\")\n    }\n}\n\njacocoTestReport {\n    reports {\n        xml.required = true\n        html.required = false\n    }\n}\n\njavadoc {\n    options.overview = \"src/main/javadoc/overview.html\"\n}\n\ndependencies {\n    implementation(platform(SpringBootPlugin.BOM_COORDINATES))\n    annotationProcessor(platform(SpringBootPlugin.BOM_COORDINATES))\n    developmentOnly(platform(SpringBootPlugin.BOM_COORDINATES))\n    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' // handles @ConfigurationProperties\n    implementation project(':common')\n    implementation project(':ui')\n    implementation 'org.springframework.boot:spring-boot-starter-jackson'\n    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'\n    implementation 'org.springframework.boot:spring-boot-starter-validation'\n    implementation 'com.h2database:h2'\n    implementation 'org.springframework.boot:spring-boot-starter-webmvc'\n    implementation 'org.springframework.boot:spring-boot-starter-websocket'\n    implementation('org.springframework.boot:spring-boot-starter-webclient') { // to bring in netty, but also the WebClient that we configure\n        exclude group: 'io.netty', module: 'netty-transport-native-epoll' // We don't use epoll\n        exclude group: 'io.netty', module: 'netty-codec-native-quic' // Real programmer don't eat quiche (aka we don't need HTTP/3 and it adds 10 MB by using the google quiche library)\n    }\n    implementation 'org.springframework.boot:spring-boot-starter-security'\n    implementation 'org.springframework.security:spring-security-messaging' // seems to be missing from spring-boot-starter-security\n    implementation 'org.springframework.boot:spring-boot-starter-flyway'\n    implementation 'tools.jackson.datatype:jackson-datatype-jakarta-jsonp'\n    runtimeOnly 'org.leadpony.joy:joy-classic:2.1.0' // JSON-P implementation\n    implementation \"org.bouncycastle:bcpg-jdk18on:$bouncycastleVersion\" // use bcpg-debug-jdk18on for debugger support\n    implementation \"org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion\" // use bcpkix-debug-jkd18on for debugger support\n    implementation \"org.jsoup:jsoup:$jsoupVersion\"\n    implementation 'com.github.atomashpolskiy:bt-dht:1.10'\n    implementation \"org.apache.commons:commons-lang3:$apacheCommonsLangVersion\"\n    implementation \"org.apache.commons:commons-collections4:$apacheCommonsCollectionsVersion\"\n    implementation \"org.springdoc:springdoc-openapi-starter-webmvc-api:$springOpenApiVersion\"\n    implementation \"net.java.dev.jna:jna-platform:$jnaVersion\"\n    implementation 'com.maxmind.geoip2:geoip2:5.1.0'\n    implementation \"com.google.zxing:javase:$zxingVersion\"\n    implementation 'com.sangupta:bloomfilter:0.9.0'\n    implementation \"org.springdoc:springdoc-openapi-starter-webmvc-ui:$springOpenApiVersion\"\n    implementation \"org.commonmark:commonmark:$commonMarkVersion\"\n    implementation \"org.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion\"\n    implementation \"org.graalvm.polyglot:polyglot:$graalvmVersion\"\n    implementation \"org.graalvm.polyglot:js:$graalvmVersion\"\n    implementation 'com.tianscar.javasound:javasound-speex:0.9.8'\n    implementation \"com.twelvemonkeys.imageio:imageio-webp:$twelveMonkeysVersion\"\n    implementation \"com.twelvemonkeys.imageio:imageio-jpeg:$twelveMonkeysVersion\" // Improved formats\n    implementation \"com.twelvemonkeys.imageio:imageio-bmp:$twelveMonkeysVersion\" // Adds .ico support\n    implementation \"com.twelvemonkeys.imageio:imageio-iff:$twelveMonkeysVersion\" // A blast from the past :)\n    implementation(\"com.twelvemonkeys.imageio:imageio-batik:$twelveMonkeysVersion\")\n    implementation(\"org.apache.xmlgraphics:batik-all:1.19\")\n    developmentOnly 'org.springframework.boot:spring-boot-starter-actuator'\n    developmentOnly 'org.springframework.boot:spring-boot-devtools'\n    testImplementation \"org.junit.jupiter:junit-jupiter:$junitVersion\"\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n    testImplementation('org.springframework.boot:spring-boot-starter-test') {\n        exclude group: \"com.vaadin.external.google\", module: \"android-json\"\n    }\n    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'\n    testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'\n    testImplementation 'org.springframework.boot:spring-boot-starter-webflux-test'\n    testImplementation(testFixtures(project(\":common\")))\n    testImplementation \"com.tngtech.archunit:archunit-junit5:$archunitVersion\"\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/XeresApplication.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app;\n\nimport io.xeres.app.application.environment.*;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.ui.UiStarter;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\n\nimport static io.xeres.common.properties.StartupProperties.Property.UI;\n\n@SpringBootApplication(scanBasePackageClasses = {io.xeres.app.XeresApplication.class, io.xeres.ui.UiStarter.class})\npublic class XeresApplication\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(XeresApplication.class);\n\n\t// Spring Boot requires main to be static, always\n\tstatic void main(String[] args)\n\t{\n\t\tDefaultProperties.setDefaults();\n\n\t\tCloud.checkIfRunningOnCloud();\n\t\tHostVariable.parse();\n\t\tCommandArgument.parse(args);\n\t\tLocalPortFinder.ensureFreePort();\n\n\t\tif (!StartupProperties.getBoolean(UI, true))\n\t\t{\n\t\t\tlog.info(\"no gui mode\");\n\t\t\tSpringApplication.run(XeresApplication.class, args);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.info(\"gui mode\");\n\t\t\tUiStarter.start(XeresApplication.class, args); // this starts spring as well\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/DefaultHandler.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api;\n\nimport io.swagger.v3.oas.annotations.OpenAPIDefinition;\nimport io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;\nimport io.swagger.v3.oas.annotations.enums.SecuritySchemeType;\nimport io.swagger.v3.oas.annotations.info.Contact;\nimport io.swagger.v3.oas.annotations.info.Info;\nimport io.swagger.v3.oas.annotations.info.License;\nimport io.swagger.v3.oas.annotations.security.SecurityRequirement;\nimport io.swagger.v3.oas.annotations.security.SecurityScheme;\nimport io.xeres.app.api.exception.UnprocessableEntityException;\nimport io.xeres.common.AppName;\nimport jakarta.persistence.EntityExistsException;\nimport jakarta.persistence.EntityNotFoundException;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.apache.commons.lang3.exception.ExceptionUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.*;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.MethodArgumentNotValidException;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.RestControllerAdvice;\nimport org.springframework.web.context.request.WebRequest;\nimport org.springframework.web.server.ResponseStatusException;\nimport org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;\n\nimport java.net.UnknownHostException;\nimport java.util.Arrays;\nimport java.util.NoSuchElementException;\nimport java.util.Optional;\n\n@RestControllerAdvice\n@OpenAPIDefinition(\n\t\tinfo = @Info(\n\t\t\t\ttitle = AppName.NAME,\n\t\t\t\tversion = \"1.0\",\n\t\t\t\tsummary = \"A decentralized and secure application for communication and sharing\",\n\t\t\t\tlicense = @License(name = \"GPL v3\", url = \"https://www.gnu.org/licenses/gpl-3.0.en.html\"),\n\t\t\t\tcontact = @Contact(name = \"Xeres\", url = \"https://xeres.io\"),\n\t\t\t\tdescription = \"\"\"\n\t\t\t\t\t\tThis is the REST interface for controlling the application. Don't forget to use the _Authorize_ button on the right to enter the same\n\t\t\t\t\t\tcredentials as the ones in _Settings / Remote_ (you can cut & paste, don't forget to make the password visible first or it will copy asterisks).\n\t\t\t\t\t\t\n\t\t\t\t\t\t**Note**: because some swagger-ui developers are [braindead](https://github.com/swagger-api/swagger-ui/issues/2030), 64-bit values output are truncated to 53-bit ones.\n\t\t\t\t\t\t\"\"\"\n\t\t),\n\t\tsecurity = @SecurityRequirement(\n\t\t\t\tname = \"api\" // Mark all endpoints as authenticated. Otherwise, remove and add @SecurityRequirement(name = \"api\") separately to all controller classes or methods\n\t\t)\n)\n@SecurityScheme(name = \"api\", scheme = \"basic\", type = SecuritySchemeType.HTTP, in = SecuritySchemeIn.HEADER)\npublic class DefaultHandler extends ResponseEntityExceptionHandler\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DefaultHandler.class);\n\tpublic static final String TRACE = \"trace\";\n\n\t@ExceptionHandler({\n\t\t\tNoSuchElementException.class,\n\t\t\tEntityNotFoundException.class,\n\t\t\tUnknownHostException.class})\n\tpublic ErrorResponse handleNotFoundException(Exception e)\n\t{\n\t\tlogError(e, log.isDebugEnabled());\n\t\treturn ErrorResponse.builder(e, HttpStatus.NOT_FOUND, e.getMessage())\n\t\t\t\t.property(TRACE, ExceptionUtils.getStackTrace(e))\n\t\t\t\t.build();\n\t}\n\n\t@ExceptionHandler(UnprocessableEntityException.class)\n\tpublic ErrorResponse handleUnprocessableEntityException(UnprocessableEntityException e)\n\t{\n\t\tlogError(e, log.isDebugEnabled());\n\t\treturn ErrorResponse.builder(e, HttpStatus.UNPROCESSABLE_CONTENT, e.getMessage())\n\t\t\t\t.property(TRACE, ExceptionUtils.getStackTrace(e))\n\t\t\t\t.build();\n\t}\n\n\t@ExceptionHandler(EntityExistsException.class)\n\tpublic ErrorResponse handleEntityExistsException(EntityExistsException e)\n\t{\n\t\tlogError(e, log.isDebugEnabled());\n\t\treturn ErrorResponse.builder(e, HttpStatus.CONFLICT, e.getMessage())\n\t\t\t\t.property(TRACE, ExceptionUtils.getStackTrace(e))\n\t\t\t\t.build();\n\t}\n\n\t@ExceptionHandler(IllegalArgumentException.class)\n\tpublic ErrorResponse handleIllegalArgumentException(IllegalArgumentException e)\n\t{\n\t\tlogError(e, log.isDebugEnabled());\n\t\treturn ErrorResponse.builder(e, HttpStatus.BAD_REQUEST, e.getMessage())\n\t\t\t\t.property(TRACE, ExceptionUtils.getStackTrace(e))\n\t\t\t\t.build();\n\t}\n\n\t@ExceptionHandler(Exception.class)\n\tpublic ErrorResponse handleException(Exception e)\n\t{\n\t\tlogError(e, true);\n\t\treturn ErrorResponse.builder(e, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage())\n\t\t\t\t.property(TRACE, ExceptionUtils.getStackTrace(e))\n\t\t\t\t.build();\n\t}\n\n\t/**\n\t * Generates a ResponseStatusException. Those are typically done from media endpoints\n\t * and there's no way to put JSON error messages in there, so just ignore them.\n\t *\n\t * @param e the exception\n\t * @return a ResponseEntity with just the status code and no message\n\t */\n\t@ExceptionHandler(ResponseStatusException.class)\n\tpublic ResponseEntity<Void> handleResponseStatusException(ResponseStatusException e)\n\t{\n\t\treturn new ResponseEntity<>(e.getStatusCode());\n\t}\n\n\t// This one has to use an override\n\t@Override\n\tprotected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request)\n\t{\n\t\tvar problemDetail = handleValidationException(ex);\n\t\treturn ResponseEntity.status(status.value()).body(problemDetail);\n\t}\n\n\tprivate ProblemDetail handleValidationException(MethodArgumentNotValidException ex)\n\t{\n\t\tvar details = Optional.of(ex.getDetailMessageArguments())\n\t\t\t\t.map(args -> Arrays.stream(args)\n\t\t\t\t\t\t.filter(msg -> !ObjectUtils.isEmpty(msg))\n\t\t\t\t\t\t.reduce(\"Wrong input,\", (a, b) -> a + \" \" + b)\n\t\t\t\t)\n\t\t\t\t.orElse(\"\").toString();\n\t\tvar problemDetail = ProblemDetail.forStatusAndDetail(ex.getStatusCode(), details);\n\t\tproblemDetail.setInstance(ex.getBody().getInstance());\n\t\treturn problemDetail;\n\t}\n\n\tprivate void logError(Exception e, boolean withStackTrace)\n\t{\n\t\tif (withStackTrace)\n\t\t{\n\t\t\tlog.error(\"{}: {}\", e.getClass().getSimpleName(), e.getMessage(), e);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.error(\"{}: {}\", e.getClass().getSimpleName(), e.getMessage());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/board/BoardController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.board;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.service.BoardMessageService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.board.BoardRsService;\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.common.dto.board.BoardGroupDTO;\nimport io.xeres.common.dto.board.BoardMessageDTO;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.rest.board.UpdateBoardMessageReadRequest;\nimport io.xeres.common.util.image.ImageUtils;\nimport jakarta.validation.Valid;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.springframework.core.io.InputStreamResource;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.domain.Sort.Direction;\nimport org.springframework.data.web.PageableDefault;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.multipart.MultipartFile;\nimport org.springframework.web.server.ResponseStatusException;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.database.model.board.BoardMapper.*;\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\n\n@Tag(name = \"Boards\", description = \"Boards\")\n@RestController\n@RequestMapping(value = BOARDS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class BoardController\n{\n\tprivate final BoardRsService boardRsService;\n\tprivate final IdentityService identityService;\n\tprivate final BoardMessageService boardMessageService;\n\tprivate final UnHtmlService unHtmlService;\n\n\tpublic BoardController(BoardRsService boardRsService, IdentityService identityService, BoardMessageService boardMessageService, UnHtmlService unHtmlService)\n\t{\n\t\tthis.boardRsService = boardRsService;\n\t\tthis.identityService = identityService;\n\t\tthis.boardMessageService = boardMessageService;\n\t\tthis.unHtmlService = unHtmlService;\n\t}\n\n\t@GetMapping(\"/groups\")\n\t@Operation(summary = \"Gets the list of boards\")\n\tpublic List<BoardGroupDTO> getBoardGroups()\n\t{\n\t\treturn toDTOs(boardRsService.findAllGroups());\n\t}\n\n\t@PostMapping(value = \"/groups\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Creates a board\")\n\t@ApiResponse(responseCode = \"201\", description = \"Board created successfully\", headers = @Header(name = \"Board\", description = \"The location of the created board\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createBoardGroup(@RequestParam(value = \"name\") String name,\n\t                                             @RequestParam(value = \"description\") String description,\n\t                                             @RequestParam(value = \"image\", required = false) MultipartFile imageFile) throws IOException\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\tvar id = boardRsService.createBoardGroup(ownIdentity.getGxsId(), name, description, imageFile);\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(BOARDS_PATH + \"/groups/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@PutMapping(value = \"/groups/{groupId}\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Updates a board\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void updateBoardGroup(@PathVariable long groupId,\n\t                             @RequestParam(value = \"name\") String name,\n\t                             @RequestParam(value = \"description\") String description,\n\t                             @RequestParam(value = \"image\", required = false) MultipartFile imageFile,\n\t                             @RequestParam(value = \"updateImage\", required = false) Boolean updateImage) throws IOException\n\t{\n\t\tboardRsService.updateBoardGroup(groupId, name, description, imageFile, updateImage != null ? updateImage : false);\n\t}\n\n\t@GetMapping(value = \"/groups/{id}/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})\n\t@Operation(summary = \"Returns an board's image\")\n\t@ApiResponse(responseCode = \"200\", description = \"Board image found\")\n\t@ApiResponse(responseCode = \"204\", description = \"Board image is empty\")\n\t@ApiResponse(responseCode = \"404\", description = \"Board not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<InputStreamResource> downloadBoardGroupImage(@PathVariable long id)\n\t{\n\t\tvar group = boardRsService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype\n\t\tvar imageType = ImageUtils.getImageMimeType(group.getImage());\n\t\tif (imageType == null)\n\t\t{\n\t\t\treturn ResponseEntity.noContent().build();\n\t\t}\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(group.getImage().length)\n\t\t\t\t.contentType(imageType)\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(group.getImage())));\n\t}\n\n\t@GetMapping(\"/groups/{groupId}\")\n\t@Operation(summary = \"Gets the details of a board\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic BoardGroupDTO getBoardGroupById(@PathVariable long groupId)\n\t{\n\t\treturn toDTO(boardRsService.findById(groupId).orElseThrow());\n\t}\n\n\t@GetMapping(\"/groups/{groupId}/unread-count\")\n\t@Operation(summary = \"Get the unread count of a board\")\n\tpublic int getBoardUnreadCount(@PathVariable long groupId)\n\t{\n\t\treturn boardRsService.getUnreadCount(groupId);\n\t}\n\n\t@PutMapping(\"/groups/{groupId}/subscription\")\n\t@Operation(summary = \"Subscribes to a board\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void subscribeToBoardGroup(@PathVariable long groupId)\n\t{\n\t\tboardRsService.subscribeToBoardGroup(groupId);\n\t}\n\n\t@PutMapping(\"/groups/{groupId}/read\")\n\t@Operation(summary = \"Sets all messages in the group as read or unread\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void setAllGroupMessagesReadState(@PathVariable long groupId, @RequestParam(value = \"read\") Boolean read)\n\t{\n\t\tboardRsService.setAllGroupMessagesReadState(groupId, read);\n\t}\n\n\t@DeleteMapping(\"/groups/{groupId}/subscription\")\n\t@Operation(summary = \"Unsubscribes from a board\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void unsubscribeFromBoardGroup(@PathVariable long groupId)\n\t{\n\t\tboardRsService.unsubscribeFromBoardGroup(groupId);\n\t}\n\n\t@GetMapping(\"/groups/{groupId}/messages\")\n\t@Operation(summary = \"Gets the messages from a group\")\n\tpublic Page<BoardMessageDTO> getBoardMessages(@PathVariable long groupId, @PageableDefault(size = 50, sort = {\"published\"}, direction = Direction.DESC) Pageable pageable)\n\t{\n\t\tvar boardMessages = boardRsService.findAllMessages(groupId, pageable);\n\n\t\treturn new PageImpl<>(toBoardMessageDTOs(unHtmlService,\n\t\t\t\tboardMessages,\n\t\t\t\tboardMessageService.getAuthorsMapFromMessages(boardMessages),\n\t\t\t\tboardMessageService.getMessagesMapFromSummaries(groupId, boardMessages)),\n\t\t\t\tpageable,\n\t\t\t\tboardMessages.getTotalElements());\n\t}\n\n\t@GetMapping(\"/messages/{messageId}\")\n\t@Operation(summary = \"Gets a message\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic BoardMessageDTO getBoardMessage(@PathVariable long messageId)\n\t{\n\t\tvar boardMessage = boardRsService.findMessageById(messageId).orElseThrow();\n\t\tObjects.requireNonNull(boardMessage, \"Board message \" + messageId + \" not found\");\n\n\t\tvar author = identityService.findByGxsId(boardMessage.getAuthorGxsId());\n\n\t\tHashSet<MsgId> messageSet = HashSet.newHashSet(2); // they can be null so no Set.of() possible\n\t\tCollectionUtils.addIgnoreNull(messageSet, boardMessage.getOriginalMsgId());\n\t\tCollectionUtils.addIgnoreNull(messageSet, boardMessage.getParentMsgId());\n\n\t\tvar messages = boardRsService.findAllMessagesIncludingOlds(boardMessage.getGxsId(), messageSet).stream()\n\t\t\t\t.collect(Collectors.toMap(BoardMessageItem::getMsgId, BoardMessageItem::getId));\n\n\t\treturn toDTO(\n\t\t\t\tunHtmlService,\n\t\t\t\tboardMessage,\n\t\t\t\tauthor.map(GxsGroupItem::getName).orElse(null),\n\t\t\t\tmessages.getOrDefault(boardMessage.getOriginalMsgId(), 0L),\n\t\t\t\tmessages.getOrDefault(boardMessage.getParentMsgId(), 0L)\n\t\t);\n\t}\n\n\t@PostMapping(value = \"/messages\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Creates a board message\")\n\t@ApiResponse(responseCode = \"201\", description = \"Board message created successfully\", headers = @Header(name = \"Message\", description = \"The location of the created message\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createBoardMessage(@RequestParam(value = \"boardId\") long boardId,\n\t                                               @RequestParam(value = \"title\") String title,\n\t                                               @RequestParam(value = \"content\", required = false) String content,\n\t                                               @RequestParam(value = \"link\", required = false) String link,\n\t                                               @RequestParam(value = \"image\", required = false) MultipartFile imageFile) throws IOException\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\tvar id = boardRsService.createBoardMessage(\n\t\t\t\townIdentity,\n\t\t\t\tboardId,\n\t\t\t\ttitle,\n\t\t\t\tcontent,\n\t\t\t\tlink,\n\t\t\t\timageFile\n\t\t);\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(BOARDS_PATH + \"/messages/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@GetMapping(value = \"/messages/{id}/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_GIF_VALUE, \"image/webp\"})\n\t@Operation(summary = \"Returns an board message's image\")\n\t@ApiResponse(responseCode = \"200\", description = \"Board message image found\")\n\t@ApiResponse(responseCode = \"204\", description = \"Board message image is empty\")\n\t@ApiResponse(responseCode = \"404\", description = \"Board not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<InputStreamResource> downloadBoardMessageImage(@PathVariable long id)\n\t{\n\t\tvar group = boardRsService.findMessageById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype\n\t\tvar imageType = ImageUtils.getImageMimeType(group.getImage());\n\t\tif (imageType == null)\n\t\t{\n\t\t\treturn ResponseEntity.noContent().build();\n\t\t}\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(group.getImage().length)\n\t\t\t\t.contentType(imageType)\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(group.getImage())));\n\t}\n\n\t@PatchMapping(\"/messages\")\n\t@Operation(summary = \"Modifies board message read state\")\n\t@ResponseStatus(HttpStatus.OK)\n\tpublic void setBoardMessageReadState(@Valid @RequestBody UpdateBoardMessageReadRequest request)\n\t{\n\t\tboardRsService.setMessageReadState(request.messageId(), request.read());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/channel/ChannelController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.channel;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.database.model.channel.ChannelMapper;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.service.ChannelMessageService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.channel.ChannelRsService;\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.common.dto.channel.ChannelFileDTO;\nimport io.xeres.common.dto.channel.ChannelGroupDTO;\nimport io.xeres.common.dto.channel.ChannelMessageDTO;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.rest.channel.UpdateChannelMessageReadRequest;\nimport io.xeres.common.util.image.ImageUtils;\nimport jakarta.validation.Valid;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.springframework.core.io.InputStreamResource;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.domain.Sort;\nimport org.springframework.data.web.PageableDefault;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.multipart.MultipartFile;\nimport org.springframework.web.server.ResponseStatusException;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.database.model.channel.ChannelMapper.*;\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\n\n@Tag(name = \"Channels\", description = \"Channels\")\n@RestController\n@RequestMapping(value = CHANNELS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ChannelController\n{\n\tprivate final ChannelRsService channelRsService;\n\tprivate final IdentityService identityService;\n\tprivate final ChannelMessageService channelMessageService;\n\tprivate final UnHtmlService unHtmlService;\n\n\tpublic ChannelController(ChannelRsService channelRsService, IdentityService identityService, ChannelMessageService channelMessageService, UnHtmlService unHtmlService)\n\t{\n\t\tthis.channelRsService = channelRsService;\n\t\tthis.identityService = identityService;\n\t\tthis.channelMessageService = channelMessageService;\n\t\tthis.unHtmlService = unHtmlService;\n\t}\n\n\t@GetMapping(\"/groups\")\n\t@Operation(summary = \"Gets the list of channels\")\n\tpublic List<ChannelGroupDTO> getChannelGroups()\n\t{\n\t\treturn toDTOs(channelRsService.findAllGroups());\n\t}\n\n\t@PostMapping(value = \"/groups\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Creates a channel\")\n\t@ApiResponse(responseCode = \"201\", description = \"Channel created successfully\", headers = @Header(name = \"Channel\", description = \"The location of the created channel\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createChannelGroup(@RequestParam(value = \"name\") String name,\n\t                                               @RequestParam(value = \"description\") String description,\n\t                                               @RequestParam(value = \"image\", required = false) MultipartFile imageFile) throws IOException\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\tvar id = channelRsService.createChannelGroup(ownIdentity.getGxsId(), name, description, imageFile);\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(CHANNELS_PATH + \"/groups/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@PutMapping(value = \"/groups/{groupId}\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Updates a channel\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void updateChannelGroup(@PathVariable long groupId,\n\t                               @RequestParam(value = \"name\") String name,\n\t                               @RequestParam(value = \"description\") String description,\n\t                               @RequestParam(value = \"image\", required = false) MultipartFile imageFile,\n\t                               @RequestParam(value = \"updateImage\", required = false) Boolean updateImage) throws IOException\n\t{\n\t\tchannelRsService.updateChannelGroup(groupId, name, description, imageFile, updateImage != null ? updateImage : false);\n\t}\n\n\t@GetMapping(value = \"/groups/{id}/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})\n\t@Operation(summary = \"Returns a channel's image\")\n\t@ApiResponse(responseCode = \"200\", description = \"Channel's image found\")\n\t@ApiResponse(responseCode = \"204\", description = \"Channel's image is empty\")\n\t@ApiResponse(responseCode = \"404\", description = \"Channel not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<InputStreamResource> downloadChannelGroupImage(@PathVariable long id)\n\t{\n\t\tvar group = channelRsService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype\n\t\tvar imageType = ImageUtils.getImageMimeType(group.getImage());\n\t\tif (imageType == null)\n\t\t{\n\t\t\treturn ResponseEntity.noContent().build();\n\t\t}\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(group.getImage().length)\n\t\t\t\t.contentType(imageType)\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(group.getImage())));\n\t}\n\n\t@GetMapping(\"/groups/{groupId}\")\n\t@Operation(summary = \"Gets the details of a channel\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ChannelGroupDTO getChannelGroupById(@PathVariable long groupId)\n\t{\n\t\treturn toDTO(channelRsService.findById(groupId).orElseThrow());\n\t}\n\n\t@GetMapping(\"/groups/{groupId}/unread-count\")\n\t@Operation(summary = \"Get the unread count of a channel\")\n\tpublic int getChannelUnreadCount(@PathVariable long groupId)\n\t{\n\t\treturn channelRsService.getUnreadCount(groupId);\n\t}\n\n\t@PutMapping(\"/groups/{groupId}/subscription\")\n\t@Operation(summary = \"Subscribes to a channel\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void subscribeToChannelGroup(@PathVariable long groupId)\n\t{\n\t\tchannelRsService.subscribeToChannelGroup(groupId);\n\t}\n\n\t@PutMapping(\"/groups/{groupId}/read\")\n\t@Operation(summary = \"Sets all messages in the group as read or unread\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void setAllGroupMessagesReadState(@PathVariable long groupId, @RequestParam(value = \"read\") Boolean read)\n\t{\n\t\tchannelRsService.setAllGroupMessagesReadState(groupId, read);\n\t}\n\n\t@DeleteMapping(\"/groups/{groupId}/subscription\")\n\t@Operation(summary = \"Unsubscribes from a channel\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void unsubscribeFromChannelGroup(@PathVariable long groupId)\n\t{\n\t\tchannelRsService.unsubscribeFromChannelGroup(groupId);\n\t}\n\n\t@GetMapping(\"/groups/{groupId}/messages\")\n\t@Operation(summary = \"Gets the summary of messages in a group\")\n\tpublic Page<ChannelMessageDTO> getChannelMessages(@PathVariable long groupId, @PageableDefault(size = 50, sort = {\"published\"}, direction = Sort.Direction.DESC) Pageable pageable)\n\t{\n\t\tvar channelMessages = channelRsService.findAllMessages(groupId, pageable);\n\n\t\treturn new PageImpl<>(toSummaryMessageDTOs(channelMessages,\n\t\t\t\tchannelMessageService.getAuthorsMapFromMessages(channelMessages),\n\t\t\t\tchannelMessageService.getMessagesMapFromSummaries(groupId, channelMessages)),\n\t\t\t\tpageable,\n\t\t\t\tchannelMessages.getTotalElements());\n\t}\n\n\t@GetMapping(\"/messages/{messageId}\")\n\t@Operation(summary = \"Gets a message\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ChannelMessageDTO getChannelMessage(@PathVariable long messageId)\n\t{\n\t\tvar channelMessage = channelRsService.findMessageById(messageId).orElseThrow();\n\t\tObjects.requireNonNull(channelMessage, \"Channel message \" + messageId + \" not found\");\n\n\t\tvar author = identityService.findByGxsId(channelMessage.getAuthorGxsId());\n\n\t\tHashSet<MsgId> messageSet = HashSet.newHashSet(2); // they can be null so no Set.of() possible\n\t\tCollectionUtils.addIgnoreNull(messageSet, channelMessage.getOriginalMsgId());\n\t\tCollectionUtils.addIgnoreNull(messageSet, channelMessage.getParentMsgId());\n\n\t\tvar messages = channelRsService.findAllMessagesIncludingOlds(channelMessage.getGxsId(), messageSet).stream()\n\t\t\t\t.collect(Collectors.toMap(ChannelMessageItem::getMsgId, ChannelMessageItem::getId));\n\n\t\treturn toDTO(\n\t\t\t\tunHtmlService,\n\t\t\t\tchannelMessage,\n\t\t\t\tauthor.map(GxsGroupItem::getName).orElse(null),\n\t\t\t\tmessages.getOrDefault(channelMessage.getOriginalMsgId(), 0L),\n\t\t\t\tmessages.getOrDefault(channelMessage.getParentMsgId(), 0L),\n\t\t\t\ttrue\n\t\t);\n\t}\n\n\t@PostMapping(value = \"/messages\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Creates a channel message\")\n\t@ApiResponse(responseCode = \"201\", description = \"Channel message created successfully\", headers = @Header(name = \"Message\", description = \"The location of the created message\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createChannelMessage(@RequestParam(value = \"channelId\") long channelId,\n\t                                                 @RequestParam(value = \"title\") String title,\n\t                                                 @RequestParam(value = \"content\", required = false) String content,\n\t                                                 @RequestParam(value = \"originalId\", required = false) Long originalId,\n\t                                                 @RequestParam(value = \"image\", required = false) MultipartFile imageFile,\n\t                                                 @RequestPart(value = \"files\", required = false) List<ChannelFileDTO> files) throws IOException\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\n\t\tvar id = channelRsService.createChannelMessage(\n\t\t\t\townIdentity,\n\t\t\t\tchannelId,\n\t\t\t\ttitle,\n\t\t\t\tcontent,\n\t\t\t\timageFile,\n\t\t\t\tChannelMapper.toFileItems(files),\n\t\t\t\toriginalId != null ? originalId : 0L\n\t\t);\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(CHANNELS_PATH + \"/messages/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@GetMapping(value = \"/messages/{id}/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})\n\t@Operation(summary = \"Returns a channel message's image\")\n\t@ApiResponse(responseCode = \"200\", description = \"Channel message image found\")\n\t@ApiResponse(responseCode = \"204\", description = \"Channel message image is empty\")\n\t@ApiResponse(responseCode = \"404\", description = \"Channel not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<InputStreamResource> downloadChannelMessageImage(@PathVariable long id)\n\t{\n\t\tvar group = channelRsService.findMessageById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype\n\t\tvar imageType = ImageUtils.getImageMimeType(group.getImage());\n\t\tif (imageType == null)\n\t\t{\n\t\t\treturn ResponseEntity.noContent().build();\n\t\t}\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(group.getImage().length)\n\t\t\t\t.contentType(imageType)\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(group.getImage())));\n\t}\n\n\t@PatchMapping(\"/messages\")\n\t@Operation(summary = \"Modifies channel message read state\")\n\t@ResponseStatus(HttpStatus.OK)\n\tpublic void setChannelMessageReadState(@Valid @RequestBody UpdateChannelMessageReadRequest request)\n\t{\n\t\tchannelRsService.setMessageReadState(request.messageId(), request.read());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/chat/ChatController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.chat;\n\nimport io.swagger.v3.oas.annotations.ExternalDocumentation;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.xrs.service.chat.ChatBacklogService;\nimport io.xeres.app.xrs.service.chat.ChatRsService;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.common.dto.chat.ChatBacklogDTO;\nimport io.xeres.common.dto.chat.ChatRoomBacklogDTO;\nimport io.xeres.common.dto.chat.ChatRoomContextDTO;\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.rest.chat.ChatRoomVisibility;\nimport io.xeres.common.rest.chat.CreateChatRoomRequest;\nimport io.xeres.common.rest.chat.DistantChatRequest;\nimport io.xeres.common.rest.chat.InviteToChatRoomRequest;\nimport jakarta.persistence.EntityExistsException;\nimport jakarta.persistence.EntityNotFoundException;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport org.springframework.format.annotation.DateTimeFormat;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.LocalDateTime;\nimport java.time.ZoneOffset;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.database.model.chat.ChatMapper.fromDistantChatBacklogToChatBacklogDTOs;\nimport static io.xeres.app.database.model.chat.ChatMapper.toChatBacklogDTOs;\nimport static io.xeres.app.database.model.chat.ChatMapper.toChatRoomBacklogDTOs;\nimport static io.xeres.app.database.model.chat.ChatMapper.toDTO;\nimport static io.xeres.app.database.model.location.LocationMapper.toDTO;\nimport static io.xeres.common.rest.PathConfig.CHAT_PATH;\n\n@Tag(name = \"Chat\", description = \"Chat rooms, private messages, distant chats, ...\", externalDocs = @ExternalDocumentation(url = \"https://github.com/zapek/Xeres/wiki/Chat\", description = \"Chat protocol\"))\n@RestController\n@RequestMapping(value = CHAT_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ChatController\n{\n\tprivate static final int PRIVATE_CHAT_DEFAULT_MAX_LINES = 20;\n\tprivate static final Duration PRIVATE_CHAT_DEFAULT_DURATION = Duration.ofDays(7);\n\tprivate static final int ROOM_CHAT_DEFAULT_MAX_LINES = 50;\n\tprivate static final Duration ROOM_CHAT_DEFAULT_DURATION = Duration.ofDays(7);\n\n\tprivate final ChatRsService chatRsService;\n\tprivate final ChatBacklogService chatBacklogService;\n\tprivate final LocationService locationService;\n\tprivate final IdentityService identityService;\n\n\tpublic ChatController(ChatRsService chatRsService, ChatBacklogService chatBacklogService, LocationService locationService, IdentityService identityService)\n\t{\n\t\tthis.chatRsService = chatRsService;\n\t\tthis.chatBacklogService = chatBacklogService;\n\t\tthis.locationService = locationService;\n\t\tthis.identityService = identityService;\n\t}\n\n\t@PostMapping(\"/rooms\")\n\t@Operation(summary = \"Creates a chat room\")\n\t@ApiResponse(responseCode = \"201\", description = \"Chat room created successfully\", headers = @Header(name = \"Room\", description = \"The location of the created chat room\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createChatRoom(@Valid @RequestBody CreateChatRoomRequest createChatRoomRequest)\n\t{\n\t\tvar id = chatRsService.createChatRoom(createChatRoomRequest.name(),\n\t\t\t\tcreateChatRoomRequest.topic(),\n\t\t\t\tcreateChatRoomRequest.visibility() == ChatRoomVisibility.PUBLIC ? EnumSet.of(RoomFlags.PUBLIC) : EnumSet.noneOf(RoomFlags.class),\n\t\t\t\tcreateChatRoomRequest.signedIdentities());\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(CHAT_PATH + \"/rooms/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@PostMapping(\"/rooms/invite\")\n\t@Operation(summary = \"Invites one or several locations to a chat room\")\n\tpublic void inviteToChatRoom(@Valid @RequestBody InviteToChatRoomRequest inviteToChatRoomRequest)\n\t{\n\t\tchatRsService.inviteLocationsToChatRoom(inviteToChatRoomRequest.chatRoomId(), inviteToChatRoomRequest.locationIdentifiers().stream()\n\t\t\t\t.map(LocationIdentifier::fromString)\n\t\t\t\t.collect(Collectors.toSet()));\n\t}\n\n\t@PutMapping(\"/rooms/{id}/subscription\")\n\t@Operation(summary = \"Subscribes to a chat room\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void subscribeToChatRoom(@PathVariable @Parameter(description = \"The room's unique 64-bit identifier\") long id)\n\t{\n\t\tchatRsService.joinChatRoom(id);\n\t}\n\n\t@DeleteMapping(\"/rooms/{id}/subscription\")\n\t@Operation(summary = \"Unsubscribes from a chat room\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void unsubscribeFromChatRoom(@PathVariable @Parameter(description = \"The room's unique 64-bit identifier\") long id)\n\t{\n\t\tchatRsService.leaveChatRoom(id);\n\t}\n\n\t@GetMapping(\"/rooms\")\n\t@Operation(summary = \"Gets a chat room context\", description = \"The context contains all rooms, status, current nickname, etc...\")\n\tpublic ChatRoomContextDTO getChatRoomContext()\n\t{\n\t\treturn toDTO(chatRsService.getChatRoomContext());\n\t}\n\n\t@GetMapping(\"/rooms/{roomId}/messages\")\n\t@Operation(summary = \"Gets the chat room messages backlog\")\n\t@ApiResponse(responseCode = \"200\", description = \"OK\")\n\t@ApiResponse(responseCode = \"404\", description = \"No room found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic List<ChatRoomBacklogDTO> getChatRoomMessages(@PathVariable @Parameter(description = \"The room's unique 64-bit identifier\") long roomId,\n\t                                                    @RequestParam(value = \"maxLines\", required = false) @Min(1) @Max(500) Integer maxLines,\n\t                                                    @RequestParam(value = \"from\", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from)\n\t{\n\t\treturn toChatRoomBacklogDTOs(chatBacklogService.getChatRoomMessages(\n\t\t\t\troomId,\n\t\t\t\tfrom != null ? from.toInstant(ZoneOffset.UTC) : Instant.now().minus(ROOM_CHAT_DEFAULT_DURATION),\n\t\t\t\tmaxLines != null ? maxLines : ROOM_CHAT_DEFAULT_MAX_LINES));\n\t}\n\n\t@DeleteMapping(\"/rooms/{roomId}/messages\")\n\t@Operation(summary = \"Clears the chat room messages backlog of a given chat room\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\t@ApiResponse(responseCode = \"404\", description = \"No chat room found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic void deleteChatRoomMessages(@PathVariable @Parameter(description = \"The room's unique 64-bit identifier\") long roomId)\n\t{\n\t\tchatBacklogService.deleteChatRoomMessages(roomId);\n\t}\n\n\t@GetMapping(\"/chats/{locationId}/messages\")\n\t@Operation(summary = \"Gets the private chat messages backlog\")\n\t@ApiResponse(responseCode = \"200\", description = \"OK\")\n\t@ApiResponse(responseCode = \"404\", description = \"No location found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic List<ChatBacklogDTO> getChatMessages(@PathVariable @Parameter(description = \"The location id\") long locationId,\n\t                                            @RequestParam(value = \"maxLines\", required = false) @Min(1) @Max(500) Integer maxLines,\n\t                                            @RequestParam(value = \"from\", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from)\n\t{\n\t\tvar location = locationService.findLocationById(locationId).orElseThrow();\n\t\treturn toChatBacklogDTOs(chatBacklogService.getMessages(\n\t\t\t\tlocation,\n\t\t\t\tfrom != null ? from.toInstant(ZoneOffset.UTC) : Instant.now().minus(PRIVATE_CHAT_DEFAULT_DURATION),\n\t\t\t\tmaxLines != null ? maxLines : PRIVATE_CHAT_DEFAULT_MAX_LINES));\n\t}\n\n\t@DeleteMapping(\"/chats/{locationId}/messages\")\n\t@Operation(summary = \"Clears the private chat messages backlog of a given location\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\t@ApiResponse(responseCode = \"404\", description = \"No location found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic void deleteChatMessages(@PathVariable @Parameter(description = \"The location id\") long locationId)\n\t{\n\t\tvar location = locationService.findLocationById(locationId).orElseThrow();\n\t\tchatBacklogService.deleteMessages(location);\n\t}\n\n\t@PostMapping(\"/distant-chats\")\n\t@Operation(summary = \"Creates a distant chat\")\n\t@ApiResponse(responseCode = \"200\", description = \"OK\")\n\t@ApiResponse(responseCode = \"404\", description = \"No identity found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"409\", description = \"Tunnel already exists\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic LocationDTO createDistantChat(@Valid @RequestBody DistantChatRequest distantChatRequest)\n\t{\n\t\tvar identity = identityService.findById(distantChatRequest.identityId()).orElseThrow();\n\t\tvar location = toDTO(chatRsService.createDistantChat(identity));\n\t\tif (location == null)\n\t\t{\n\t\t\tthrow new EntityExistsException(\"Distant chat already active\");\n\t\t}\n\t\treturn location;\n\t}\n\n\t@DeleteMapping(\"/distant-chats/{identityId}\")\n\t@Operation(summary = \"Closes a distant chat\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\t@ApiResponse(responseCode = \"404\", description = \"No identity found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tvoid closeDistantChat(@PathVariable long identityId)\n\t{\n\t\tvar identity = identityService.findById(identityId).orElseThrow();\n\t\tif (!chatRsService.closeDistantChat(identity))\n\t\t{\n\t\t\tthrow new EntityNotFoundException(\"No distant chat for identity id \" + identityId);\n\t\t}\n\t}\n\n\t@GetMapping(\"/distant-chats/{identityId}/messages\")\n\t@Operation(summary = \"Gets the distant chat messages backlog of a given identity\")\n\t@ApiResponse(responseCode = \"200\", description = \"OK\")\n\t@ApiResponse(responseCode = \"404\", description = \"No identity found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic List<ChatBacklogDTO> getDistantChatMessages(@PathVariable @Parameter(description = \"The identity id\") long identityId,\n\t                                                   @RequestParam(value = \"maxLines\", required = false) @Min(1) @Max(500) Integer maxLines,\n\t                                                   @RequestParam(value = \"from\", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from)\n\t{\n\t\tvar identity = identityService.findById(identityId).orElseThrow();\n\t\treturn fromDistantChatBacklogToChatBacklogDTOs(chatBacklogService.getDistantMessages(\n\t\t\t\tidentity,\n\t\t\t\tfrom != null ? from.toInstant(ZoneOffset.UTC) : Instant.now().minus(PRIVATE_CHAT_DEFAULT_DURATION),\n\t\t\t\tmaxLines != null ? maxLines : PRIVATE_CHAT_DEFAULT_MAX_LINES));\n\t}\n\n\t@DeleteMapping(\"/distant-chats/{identityId}/messages\")\n\t@Operation(summary = \"Clears the distant chat messages backlog of a given identity\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\t@ApiResponse(responseCode = \"404\", description = \"No identity found for given id\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic void deleteDistantChatMessages(@PathVariable @Parameter(description = \"The identity id\") long identityId)\n\t{\n\t\tvar identity = identityService.findById(identityId).orElseThrow();\n\t\tchatBacklogService.deleteDistantMessages(identity);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/chat/ChatMessageController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.chat;\n\nimport io.xeres.app.service.MessageService;\nimport io.xeres.app.xrs.service.chat.ChatRsService;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.message.chat.ChatRoomMessage;\nimport jakarta.validation.Valid;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.messaging.handler.annotation.Header;\nimport org.springframework.messaging.handler.annotation.MessageExceptionHandler;\nimport org.springframework.messaging.handler.annotation.MessageMapping;\nimport org.springframework.messaging.handler.annotation.Payload;\nimport org.springframework.messaging.simp.annotation.SendToUser;\nimport org.springframework.stereotype.Controller;\n\nimport java.util.Objects;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\nimport static io.xeres.common.message.MessagePath.*;\n\n/**\n * This controller receives WebSocket messages sent to /app, which means they're produced by the app user.\n * <p>\n * <img src=\"doc-files/websocket.svg\" alt=\"WebSocket diagram\">\n */\n@Controller\n@MessageMapping(CHAT_ROOT)\npublic class ChatMessageController\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ChatMessageController.class);\n\n\tprivate final ChatRsService chatRsService;\n\tprivate final MessageService messageService;\n\n\tpublic ChatMessageController(ChatRsService chatRsService, MessageService messageService)\n\t{\n\t\tthis.chatRsService = chatRsService;\n\t\tthis.messageService = messageService;\n\t}\n\n\t@MessageMapping(CHAT_PRIVATE_DESTINATION)\n\tpublic void processPrivateChatMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatMessage chatMessage)\n\t{\n\t\tswitch (messageType)\n\t\t{\n\t\t\tcase CHAT_PRIVATE_MESSAGE ->\n\t\t\t{\n\t\t\t\tlogMessage(\"Received private chat websocket message, sending to peer location: \" + destinationId, chatMessage.getContent());\n\t\t\t\tvar locationIdentifier = LocationIdentifier.fromString(destinationId);\n\t\t\t\tchatRsService.sendPrivateMessage(locationIdentifier, chatMessage.getContent());\n\t\t\t\tchatMessage.setOwn(true);\n\t\t\t\tmessageService.sendToConsumers(chatPrivateDestination(), messageType, locationIdentifier, chatMessage);\n\t\t\t}\n\t\t\tcase CHAT_TYPING_NOTIFICATION ->\n\t\t\t{\n\t\t\t\tlog.debug(\"Sending private chat typing notification...\");\n\t\t\t\tObjects.requireNonNull(destinationId);\n\t\t\t\tchatRsService.sendPrivateTypingNotification(LocationIdentifier.fromString(destinationId));\n\t\t\t}\n\t\t\tcase CHAT_AVATAR ->\n\t\t\t{\n\t\t\t\tlog.debug(\"Requesting private chat avatar...\");\n\t\t\t\tObjects.requireNonNull(destinationId);\n\t\t\t\tchatRsService.sendAvatarRequest(LocationIdentifier.fromString(destinationId));\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t}\n\t}\n\n\t@MessageMapping(CHAT_DISTANT_DESTINATION)\n\tpublic void processDistantChatMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatMessage chatMessage)\n\t{\n\t\tswitch (messageType)\n\t\t{\n\t\t\tcase CHAT_PRIVATE_MESSAGE ->\n\t\t\t{\n\t\t\t\tlogMessage(\"Received distant chat websocket message, sending to peer gxsId: \" + destinationId, chatMessage.getContent());\n\t\t\t\tvar gxsId = GxsId.fromString(destinationId);\n\t\t\t\tchatRsService.sendPrivateMessage(gxsId, chatMessage.getContent());\n\t\t\t\tchatMessage.setOwn(true);\n\t\t\t\tmessageService.sendToConsumers(chatDistantDestination(), messageType, gxsId, chatMessage);\n\t\t\t}\n\t\t\tcase CHAT_TYPING_NOTIFICATION ->\n\t\t\t{\n\t\t\t\tlog.debug(\"Sending distant chat typing notification...\");\n\t\t\t\tObjects.requireNonNull(destinationId);\n\t\t\t\tchatRsService.sendPrivateTypingNotification(GxsId.fromString(destinationId));\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t}\n\t}\n\n\t@MessageMapping(CHAT_ROOM_DESTINATION)\n\tpublic void processChatRoomMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatRoomMessage chatRoomMessage)\n\t{\n\t\tswitch (messageType)\n\t\t{\n\t\t\tcase CHAT_ROOM_MESSAGE ->\n\t\t\t{\n\t\t\t\tlogMessage(\"Sending to room \" + destinationId + \", size: \" + (chatRoomMessage.isEmpty() ? 0 : chatRoomMessage.getContent().length()), chatRoomMessage.getContent());\n\t\t\t\tObjects.requireNonNull(destinationId);\n\t\t\t\tvar chatRoomId = Long.parseLong(destinationId);\n\t\t\t\tchatRsService.sendChatRoomMessage(chatRoomId, chatRoomMessage.getContent());\n\t\t\t\tmessageService.sendToConsumers(chatRoomDestination(), messageType, chatRoomId, chatRoomMessage);\n\t\t\t}\n\t\t\tcase CHAT_ROOM_TYPING_NOTIFICATION ->\n\t\t\t{\n\t\t\t\tlog.debug(\"Sending chat room typing notification...\");\n\t\t\t\tObjects.requireNonNull(destinationId);\n\t\t\t\tchatRsService.sendChatRoomTypingNotification(Long.parseLong(destinationId));\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t}\n\t}\n\n\t@MessageMapping(CHAT_BROADCAST_DESTINATION)\n\tpublic void processBroadcastMessageFromProducer(@Header(MESSAGE_TYPE) MessageType messageType, @Payload @Valid ChatMessage chatMessage)\n\t{\n\t\tswitch (messageType)\n\t\t{\n\t\t\tcase CHAT_BROADCAST_MESSAGE ->\n\t\t\t{\n\t\t\t\tlogMessage(\"Sending broadcast message\", chatMessage.getContent());\n\t\t\t\tchatRsService.sendBroadcastMessage(chatMessage.getContent());\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t}\n\t}\n\n\t@MessageExceptionHandler\n\t@SendToUser(DIRECT_PREFIX + \"/errors\") // XXX: how can we use this? Well, it works... just have to subscribe to it\n\tpublic String handleException(Throwable e)\n\t{\n\t\tlog.debug(\"Got exception: {}\", e.getMessage(), e);\n\t\treturn e.getMessage();\n\t}\n\n\tprivate void logMessage(String info, String message)\n\t{\n\t\tif (log.isTraceEnabled())\n\t\t{\n\t\t\tlog.trace(\"{}, content: {}\", info, message);\n\t\t}\n\t\telse if (log.isDebugEnabled())\n\t\t{\n\t\t\tlog.debug(\"{}\", info);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/chat/doc-files/websocket.puml",
    "content": "@startuml\n'https://plantuml.com/component-diagram\n\n\npackage \"UI Client\" {\n  component producer #Cyan [\n    Producer\n    /app/chat/private\n  ]\n  component consumer #Cyan [\n    Consumer\n    /topic/chat/private\n  ]\n}\n\npackage \"App Server\" {\n  [MessageHandler] #Green\n}\n\ncloud \"RS Network\" {\n  [Friend]\n}\n\n[producer] --> [MessageHandler] : /app\n[MessageHandler] --> [consumer] : /topic\n[MessageHandler] <-> [Friend]\n\n@enduml"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/chat/package-info.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Chat rooms and private messaging REST controller\n */\npackage io.xeres.app.api.controller.chat;"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/config/ConfigController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.config;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.api.exception.InternalServerErrorException;\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.service.CapabilityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.NetworkService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.backup.BackupService;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport io.xeres.app.xrs.service.status.StatusRsService;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.config.*;\nimport jakarta.validation.Valid;\nimport jakarta.xml.bind.JAXBException;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.multipart.MultipartFile;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport javax.xml.stream.XMLStreamException;\nimport java.io.IOException;\nimport java.net.UnknownHostException;\nimport java.nio.file.Paths;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.cert.CertificateException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static io.xeres.app.service.ResourceCreationState.ALREADY_EXISTS;\nimport static io.xeres.app.service.ResourceCreationState.FAILED;\nimport static io.xeres.common.rest.PathConfig.*;\n\n@Tag(name = \"Configuration\", description = \"Runtime general configuration\")\n@RestController\n@RequestMapping(value = CONFIG_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ConfigController\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ConfigController.class);\n\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final IdentityRsService identityRsService;\n\tprivate final CapabilityService capabilityService;\n\tprivate final BackupService backupService;\n\tprivate final NetworkService networkService;\n\tprivate final StatusRsService statusRsService;\n\n\tpublic ConfigController(ProfileService profileService, LocationService locationService, IdentityRsService identityRsService, CapabilityService capabilityService, BackupService backupService, NetworkService networkService, StatusRsService statusRsService)\n\t{\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.identityRsService = identityRsService;\n\t\tthis.capabilityService = capabilityService;\n\t\tthis.backupService = backupService;\n\t\tthis.networkService = networkService;\n\t\tthis.statusRsService = statusRsService;\n\t}\n\n\t@PostMapping(\"/profile\")\n\t@Operation(summary = \"Creates own profile\")\n\t@ApiResponse(responseCode = \"200\", description = \"Profile already exists\")\n\t@ApiResponse(responseCode = \"201\", description = \"Profile created successfully\", headers = @Header(name = \"Location\", description = \"The location of the created profile\", schema = @Schema(type = \"string\")))\n\t@ApiResponse(responseCode = \"422\", description = \"Profile entity cannot be processed\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"500\", description = \"Serious error\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> createOwnProfile(@Valid @RequestBody OwnProfileRequest ownProfileRequest)\n\t{\n\t\tvar name = ownProfileRequest.name();\n\t\tlog.debug(\"Processing creation of Profile {}\", name);\n\n\t\tvar status = profileService.generateProfileKeys(name);\n\n\t\tif (status == FAILED)\n\t\t{\n\t\t\tthrow new InternalServerErrorException(\"Failed to generate profile keys\");\n\t\t}\n\t\tnetworkService.checkReadiness();\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(PROFILES_PATH + \"/{id}\").buildAndExpand(1L).toUri();\n\t\treturn status == ALREADY_EXISTS ? ResponseEntity.ok().build() : ResponseEntity.created(location).build();\n\t}\n\n\t@PostMapping(\"/location\")\n\t@Operation(summary = \"Creates own location\")\n\t@ApiResponse(responseCode = \"200\", description = \"Location already exists\")\n\t@ApiResponse(responseCode = \"201\", description = \"Location created successfully\", headers = @Header(name = \"Location\", description = \"The location of the created location\", schema = @Schema(type = \"string\")))\n\t@ApiResponse(responseCode = \"500\", description = \"Serious error\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> createOwnLocation(@Valid @RequestBody OwnLocationRequest ownLocationRequest)\n\t{\n\t\tvar name = ownLocationRequest.name();\n\t\tlog.debug(\"Processing creation of Location {}\", name);\n\n\t\tvar status = locationService.generateOwnLocation(name);\n\n\t\tif (status == FAILED)\n\t\t{\n\t\t\tthrow new InternalServerErrorException(\"Failed to generate location\");\n\t\t}\n\t\tnetworkService.checkReadiness();\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(LOCATIONS_PATH + \"/{id}\").buildAndExpand(1L).toUri();\n\t\treturn status == ALREADY_EXISTS ? ResponseEntity.ok().build() : ResponseEntity.created(location).build();\n\t}\n\n\t@PutMapping(\"/location/availability\")\n\t@Operation(summary = \"Changes our own availability\")\n\t@ApiResponse(responseCode = \"200\", description = \"Availability changed successfully\")\n\t@ApiResponse(responseCode = \"400\", description = \"Location does not exist\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> changeAvailability(@RequestBody Availability availability)\n\t{\n\t\tif (!locationService.hasOwnLocation())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Location does not exist\");\n\t\t}\n\n\t\tstatusRsService.changeAvailability(availability);\n\n\t\treturn ResponseEntity.ok().build();\n\t}\n\n\t@PostMapping(\"/identity\")\n\t@Operation(summary = \"Creates own identity\")\n\t@ApiResponse(responseCode = \"200\", description = \"Identity already exists\")\n\t@ApiResponse(responseCode = \"201\", description = \"Identity created successfully\")\n\t@ApiResponse(responseCode = \"500\", description = \"Serious error\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> createOwnIdentity(@Valid @RequestBody OwnIdentityRequest ownIdentityRequest)\n\t{\n\t\tvar name = ownIdentityRequest.name();\n\t\tlog.debug(\"Creating identity {}\", name);\n\n\t\tvar status = identityRsService.generateOwnIdentity(name, !ownIdentityRequest.anonymous());\n\n\t\tif (status == FAILED)\n\t\t{\n\t\t\tthrow new InternalServerErrorException(\"Failed to generate identity\");\n\t\t}\n\t\tnetworkService.checkReadiness();\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(IDENTITIES_PATH + \"/{id}\").buildAndExpand(1L).toUri();\n\t\treturn status == ALREADY_EXISTS ? ResponseEntity.ok().build() : ResponseEntity.created(location).build();\n\t}\n\n\t@GetMapping(\"/external-ip\")\n\t@Operation(summary = \"Gets the external IP address and port\", description = \"Note that an external IP address is not strictly required if for example the host is on a public IP already.\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\t@ApiResponse(responseCode = \"404\", description = \"No location or no external IP address\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic IpAddressResponse getExternalIpAddress()\n\t{\n\t\tvar connection = locationService.findOwnLocation().orElseThrow()\n\t\t\t\t.getConnections()\n\t\t\t\t.stream()\n\t\t\t\t.filter(Connection::isExternal)\n\t\t\t\t.findFirst().orElseThrow();\n\n\t\treturn new IpAddressResponse(connection.getIp(), connection.getPort());\n\t}\n\n\t@GetMapping(\"/internal-ip\")\n\t@Operation(summary = \"Gets the internal IP address and port\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\t@ApiResponse(responseCode = \"404\", description = \"No location or no internal IP address\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic IpAddressResponse getInternalIpAddress()\n\t{\n\t\treturn new IpAddressResponse(Optional.ofNullable(networkService.getLocalIpAddress()).orElseThrow(), networkService.getPort());\n\t}\n\n\t@GetMapping(\"/hostname\")\n\t@Operation(summary = \"Gets the machine's hostname\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\t@ApiResponse(responseCode = \"404\", description = \"No hostname (host configuration problem)\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic HostnameResponse getHostname() throws UnknownHostException\n\t{\n\t\treturn new HostnameResponse(locationService.getHostname());\n\t}\n\n\t@GetMapping(\"/username\")\n\t@Operation(summary = \"Gets the OS session's username\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\t@ApiResponse(responseCode = \"404\", description = \"No username (no user session)\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic UsernameResponse getUsername()\n\t{\n\t\treturn new UsernameResponse(locationService.getUsername());\n\t}\n\n\t@GetMapping(\"/capabilities\")\n\t@Operation(summary = \"Gets the system's capabilities\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic Set<String> getCapabilities()\n\t{\n\t\treturn capabilityService.getCapabilities();\n\t}\n\n\t@GetMapping(value = \"/export\", produces = MediaType.APPLICATION_XML_VALUE)\n\t@Operation(summary = \"Exports a minimal configuration\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ResponseEntity<byte[]> getBackup() throws JAXBException\n\t{\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.header(HttpHeaders.CONTENT_DISPOSITION, \"attachment; filename=\\\"xeres_backup.xml\\\"\")\n\t\t\t\t.body(backupService.backup());\n\t}\n\n\t@PostMapping(value = \"/import\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Imports a minimal configuration\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ResponseEntity<Void> restoreFromBackup(@RequestBody MultipartFile file) throws JAXBException, IOException, InvalidKeyException, CertificateException, NoSuchAlgorithmException, InvalidKeySpecException, PGPException, XMLStreamException\n\t{\n\t\tbackupService.restore(file);\n\t\tnetworkService.checkReadiness();\n\n\t\treturn ResponseEntity.ok().build();\n\t}\n\n\t@PostMapping(value = \"/import-profile-from-rs\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Imports a RS keyring\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ResponseEntity<Void> importProfileFromRs(@RequestBody MultipartFile file, @RequestParam(value = \"locationName\") String locationName, @RequestParam(value = \"password\", required = false) String password)\n\t{\n\t\tbackupService.importProfileFromRs(file, locationName, password);\n\t\tnetworkService.checkReadiness();\n\n\t\treturn ResponseEntity.ok().build();\n\t}\n\n\t@PostMapping(value = \"/import-friends-from-rs\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Imports RS friends\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ResponseEntity<ImportRsFriendsResponse> importFriendsFromRs(@RequestBody MultipartFile file) throws JAXBException, IOException, XMLStreamException\n\t{\n\t\tvar response = backupService.importFriendsFromRs(file);\n\n\t\treturn ResponseEntity.status(response.errors() > 0 ? HttpStatus.MULTI_STATUS : HttpStatus.OK)\n\t\t\t\t.body(response);\n\t}\n\n\t@PostMapping(\"/verify-update\")\n\t@Operation(summary = \"Verify an update file\")\n\t@ApiResponse(responseCode = \"200\", description = \"File verified successfully\")\n\tpublic boolean verifyUpdate(@Valid @RequestBody VerifyUpdateRequest request)\n\t{\n\t\t//noinspection JvmTaintAnalysis\n\t\tvar path = Paths.get(request.filePath());\n\n\t\treturn backupService.verifyUpdate(path, request.signature());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/config/package-info.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Configuration related REST controller.\n * <p>This is used to store and retrieve user settings.\n * <p>Note: do not store anything UI related in there because the UI can\n * be run separately.\n */\npackage io.xeres.app.api.controller.config;"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/connection/ConnectionController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.connection;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.job.PeerConnectionJob;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.rest.connection.ConnectionRequest;\nimport jakarta.validation.Valid;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\nimport static io.xeres.app.database.model.profile.ProfileMapper.toDeepDTOs;\nimport static io.xeres.common.rest.PathConfig.CONNECTIONS_PATH;\nimport static java.util.function.Predicate.not;\n\n@Tag(name = \"Connection\", description = \"Connected peers\")\n@RestController\n@RequestMapping(value = CONNECTIONS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ConnectionController\n{\n\tprivate final LocationService locationService;\n\tprivate final PeerConnectionJob peerConnectionJob;\n\n\tpublic ConnectionController(LocationService locationService, PeerConnectionJob peerConnectionJob)\n\t{\n\t\tthis.locationService = locationService;\n\t\tthis.peerConnectionJob = peerConnectionJob;\n\t}\n\n\t@GetMapping(\"/profiles\")\n\t@Operation(summary = \"Gets all currently connected profiles\")\n\tpublic List<ProfileDTO> getConnectedProfiles()\n\t{\n\t\treturn toDeepDTOs(locationService.getConnectedLocations().stream()\n\t\t\t\t.filter(not(Location::isOwn))\n\t\t\t\t.map(Location::getProfile)\n\t\t\t\t.toList());\n\t}\n\n\t@PutMapping(\"/connect\")\n\t@Operation(summary = \"Attempts to connect to a location\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request completed successfully\")\n\t@ApiResponse(responseCode = \"404\", description = \"No location found for given identifier\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> connect(@Valid @RequestBody ConnectionRequest connectionRequest)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(LocationIdentifier.fromString(connectionRequest.locationIdentifier())).orElseThrow();\n\t\tpeerConnectionJob.connectImmediately(location, connectionRequest.connectionIndex());\n\t\treturn ResponseEntity.ok().build();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/contact/ContactController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.contact;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.ContactService;\nimport io.xeres.common.rest.contact.Contact;\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.CONTACT_PATH;\n\n@Tag(name = \"Contact\", description = \"Contacts\")\n@RestController\n@RequestMapping(value = CONTACT_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ContactController\n{\n\tprivate final ContactService contactService;\n\n\tpublic ContactController(ContactService contactService)\n\t{\n\t\tthis.contactService = contactService;\n\t}\n\n\t@GetMapping(\"\")\n\t@Operation(summary = \"Gets all the contacts\")\n\tpublic List<Contact> getContacts()\n\t{\n\t\treturn contactService.getContacts();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/file/FileController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.file;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.xrs.service.filetransfer.FileTransferRsService;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.rest.file.FileDownloadRequest;\nimport io.xeres.common.rest.file.FileProgress;\nimport io.xeres.common.rest.file.FileSearchRequest;\nimport io.xeres.common.rest.file.FileSearchResponse;\nimport jakarta.validation.Valid;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.FILES_PATH;\n\n@Tag(name = \"File\", description = \"File service\")\n@RestController\n@RequestMapping(value = FILES_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class FileController\n{\n\tprivate final FileTransferRsService fileTransferRsService;\n\n\tpublic FileController(FileTransferRsService fileTransferRsService)\n\t{\n\t\tthis.fileTransferRsService = fileTransferRsService;\n\t}\n\n\t@PostMapping(\"/search\")\n\t@Operation(summary = \"Searches for files\")\n\tpublic FileSearchResponse search(@Valid @RequestBody FileSearchRequest fileSearchRequest)\n\t{\n\t\treturn new FileSearchResponse(fileTransferRsService.turtleSearch(fileSearchRequest.name()));\n\t}\n\n\t@PostMapping(\"/download\")\n\t@Operation(summary = \"Downloads a file\")\n\t@ApiResponse(responseCode = \"200\", description = \"Download created successfully\")\n\t@ApiResponse(responseCode = \"400\", description = \"Invalid hash\")\n\tpublic long download(@RequestBody FileDownloadRequest fileDownloadRequest)\n\t{\n\t\tvar hash = Sha1Sum.fromString(fileDownloadRequest.hash());\n\t\tif (hash.isNullIdentifier())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Invalid hash\");\n\t\t}\n\t\treturn fileTransferRsService.download(fileDownloadRequest.name(), hash, fileDownloadRequest.size(), fileDownloadRequest.locationIdentifier());\n\t}\n\n\t@GetMapping(\"/downloads\")\n\t@Operation(summary = \"Shows the current downloads\")\n\tpublic List<FileProgress> getDownloads()\n\t{\n\t\treturn fileTransferRsService.getDownloadStatistics();\n\t}\n\n\t@GetMapping(\"/uploads\")\n\t@Operation(summary = \"Shows the current uploads\")\n\tpublic List<FileProgress> getUploads()\n\t{\n\t\treturn fileTransferRsService.getUploadStatistics();\n\t}\n\n\t@DeleteMapping(\"/downloads/{id}\")\n\t@Operation(summary = \"Removes/cancels a download\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void removeDownload(@PathVariable long id)\n\t{\n\t\tfileTransferRsService.removeDownload(id);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/forum/ForumController.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.forum;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.service.ForumMessageService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.forum.ForumRsService;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.common.dto.forum.ForumGroupDTO;\nimport io.xeres.common.dto.forum.ForumMessageDTO;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.rest.forum.CreateForumMessageRequest;\nimport io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest;\nimport io.xeres.common.rest.forum.UpdateForumMessageReadRequest;\nimport jakarta.validation.Valid;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.domain.Sort;\nimport org.springframework.data.web.PageableDefault;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.database.model.forum.ForumMapper.*;\nimport static io.xeres.common.rest.PathConfig.FORUMS_PATH;\n\n@Tag(name = \"Forums\", description = \"Forums\")\n@RestController\n@RequestMapping(value = FORUMS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ForumController\n{\n\tprivate final ForumRsService forumRsService;\n\tprivate final IdentityService identityService;\n\tprivate final ForumMessageService forumMessageService;\n\tprivate final UnHtmlService unHtmlService;\n\n\tpublic ForumController(ForumRsService forumRsService, IdentityService identityService, ForumMessageService forumMessageService, UnHtmlService unHtmlService)\n\t{\n\t\tthis.forumRsService = forumRsService;\n\t\tthis.identityService = identityService;\n\t\tthis.forumMessageService = forumMessageService;\n\t\tthis.unHtmlService = unHtmlService;\n\t}\n\n\t@GetMapping(\"/groups\")\n\t@Operation(summary = \"Gets the list of forums\")\n\tpublic List<ForumGroupDTO> getForumGroups()\n\t{\n\t\treturn toDTOs(forumRsService.findAllGroups());\n\t}\n\n\t@PostMapping(\"/groups\")\n\t@Operation(summary = \"Creates a forum\")\n\t@ApiResponse(responseCode = \"201\", description = \"Forum created successfully\", headers = @Header(name = \"Forum\", description = \"The location of the created forum\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createForumGroup(@Valid @RequestBody CreateOrUpdateForumGroupRequest createOrUpdateForumGroupRequest)\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\tvar id = forumRsService.createForumGroup(ownIdentity.getGxsId(), createOrUpdateForumGroupRequest.name(), createOrUpdateForumGroupRequest.description());\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(FORUMS_PATH + \"/groups/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@PutMapping(\"/groups/{groupId}\")\n\t@Operation(summary = \"Updates a forum\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void updateForumGroup(@PathVariable long groupId, @Valid @RequestBody CreateOrUpdateForumGroupRequest createOrUpdateForumGroupRequest)\n\t{\n\t\tforumRsService.updateForumGroup(groupId, createOrUpdateForumGroupRequest.name(), createOrUpdateForumGroupRequest.description());\n\t}\n\n\t@GetMapping(\"/groups/{groupId}\")\n\t@Operation(summary = \"Gets the details of a forum\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ForumGroupDTO getForumGroupById(@PathVariable long groupId)\n\t{\n\t\treturn toDTO(forumRsService.findById(groupId).orElseThrow());\n\t}\n\n\t@GetMapping(\"/groups/{groupId}/unread-count\")\n\t@Operation(summary = \"Get the unread count of a forum\")\n\tpublic int getForumUnreadCount(@PathVariable long groupId)\n\t{\n\t\treturn forumRsService.getUnreadCount(groupId);\n\t}\n\n\t@PutMapping(\"/groups/{groupId}/subscription\")\n\t@Operation(summary = \"Subscribes to a forum\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void subscribeToForumGroup(@PathVariable long groupId)\n\t{\n\t\tforumRsService.subscribeToForumGroup(groupId);\n\t}\n\n\t@PutMapping(\"/groups/{groupId}/read\")\n\t@Operation(summary = \"Mark all messages as read or unread\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void markAllMessagesAsRead(@PathVariable long groupId, @RequestParam(value = \"read\") Boolean read)\n\t{\n\t\tforumRsService.setAllGroupMessagesReadState(groupId, read);\n\t}\n\n\t@DeleteMapping(\"/groups/{groupId}/subscription\")\n\t@Operation(summary = \"Unsubscribes from a forum\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void unsubscribeFromForumGroup(@PathVariable long groupId)\n\t{\n\t\tforumRsService.unsubscribeFromForumGroup(groupId);\n\t}\n\n\t@GetMapping(\"/groups/{groupId}/messages\")\n\t@Operation(summary = \"Gets the summary of messages in a group\")\n\tpublic Page<ForumMessageDTO> getForumMessages(@PathVariable long groupId, @PageableDefault(size = 50, sort = {\"published\"}, direction = Sort.Direction.DESC) Pageable pageable)\n\t{\n\t\tvar forumMessages = forumRsService.findAllMessagesSummary(groupId, pageable);\n\n\t\treturn new PageImpl<>(toSummaryMessageDTOs(forumMessages,\n\t\t\t\tforumMessageService.getAuthorsMapFromSummaries(forumMessages),\n\t\t\t\tforumMessageService.getMessagesMapFromSummaries(groupId, forumMessages)),\n\t\t\t\tpageable,\n\t\t\t\tforumMessages.getTotalElements());\n\t}\n\n\t@GetMapping(\"/messages/{messageId}\")\n\t@Operation(summary = \"Gets a message\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ForumMessageDTO getForumMessage(@PathVariable long messageId)\n\t{\n\t\tvar forumMessage = forumRsService.findMessageById(messageId);\n\t\tObjects.requireNonNull(forumMessage, \"Forum message \" + messageId + \" not found\");\n\n\t\tvar author = identityService.findByGxsId(forumMessage.getAuthorGxsId());\n\n\t\tHashSet<MsgId> messageSet = HashSet.newHashSet(2); // they can be null so no Set.of() possible\n\t\tCollectionUtils.addIgnoreNull(messageSet, forumMessage.getOriginalMsgId());\n\t\tCollectionUtils.addIgnoreNull(messageSet, forumMessage.getParentMsgId());\n\n\t\tvar messages = forumRsService.findAllMessagesIncludingOlds(forumMessage.getGxsId(), messageSet).stream()\n\t\t\t\t.collect(Collectors.toMap(ForumMessageItem::getMsgId, ForumMessageItem::getId));\n\n\t\treturn toDTO(\n\t\t\t\tunHtmlService,\n\t\t\t\tforumMessage,\n\t\t\t\tauthor.map(GxsGroupItem::getName).orElse(null),\n\t\t\t\tmessages.getOrDefault(forumMessage.getOriginalMsgId(), 0L),\n\t\t\t\tmessages.getOrDefault(forumMessage.getParentMsgId(), 0L),\n\t\t\t\ttrue\n\t\t);\n\t}\n\n\t@PostMapping(\"/messages\")\n\t@Operation(summary = \"Creates a forum message\")\n\t@ApiResponse(responseCode = \"201\", description = \"Forum message created successfully\", headers = @Header(name = \"Message\", description = \"The location of the created message\", schema = @Schema(type = \"string\")))\n\tpublic ResponseEntity<Void> createForumMessage(@Valid @RequestBody CreateForumMessageRequest createMessageRequest)\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\tvar id = forumRsService.createForumMessage(\n\t\t\t\townIdentity,\n\t\t\t\tcreateMessageRequest.forumId(),\n\t\t\t\tcreateMessageRequest.title(),\n\t\t\t\tcreateMessageRequest.content(),\n\t\t\t\tcreateMessageRequest.parentId(),\n\t\t\t\tcreateMessageRequest.originalId()\n\t\t);\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(FORUMS_PATH + \"/messages/{id}\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@PatchMapping(\"/messages\")\n\t@Operation(summary = \"Modifies forum message read state\")\n\t@ResponseStatus(HttpStatus.OK)\n\tpublic void setForumMessageReadState(@Valid @RequestBody UpdateForumMessageReadRequest request)\n\t{\n\t\tforumRsService.setMessageReadState(request.messageId(), request.read());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/geoip/GeoIpController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.geoip;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.GeoIpService;\nimport io.xeres.common.rest.geoip.CountryResponse;\nimport jakarta.persistence.EntityNotFoundException;\nimport org.springframework.http.MediaType;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.Locale;\n\nimport static io.xeres.common.rest.PathConfig.GEOIP_PATH;\n\n@Tag(name = \"GeoIP\", description = \"GeoIP lookups\")\n@RestController\n@RequestMapping(value = GEOIP_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class GeoIpController\n{\n\tprivate final GeoIpService geoIpService;\n\n\tpublic GeoIpController(GeoIpService geoIpService)\n\t{\n\t\tthis.geoIpService = geoIpService;\n\t}\n\n\t@GetMapping(\"/{ip}\")\n\t@Operation(summary = \"Gets the ISO country code of an IP address\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\t@ApiResponse(responseCode = \"404\", description = \"No country found for IP address\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic CountryResponse getIsoCountry(@PathVariable String ip)\n\t{\n\t\tvar country = geoIpService.getCountry(ip);\n\t\tif (country == null)\n\t\t{\n\t\t\tthrow new EntityNotFoundException();\n\t\t}\n\t\treturn new CountryResponse(country.name().toLowerCase(Locale.ROOT));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/identity/IdentityController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.identity;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.identicon.IdenticonService;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport io.xeres.common.dto.identity.IdentityDTO;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.identity.Type;\nimport io.xeres.common.util.image.ImageUtils;\nimport org.springframework.core.io.InputStreamResource;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.multipart.MultipartFile;\nimport org.springframework.web.server.ResponseStatusException;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static io.xeres.app.database.model.identity.IdentityMapper.toDTO;\nimport static io.xeres.app.database.model.identity.IdentityMapper.toDTOs;\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Tag(name = \"Identities\", description = \"Identities\")\n@RestController\n@RequestMapping(value = IDENTITIES_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class IdentityController\n{\n\tprivate final IdentityService identityService;\n\tprivate final IdentityRsService identityRsService;\n\tprivate final ContactNotificationService contactNotificationService;\n\tprivate final IdenticonService identiconService;\n\n\tpublic IdentityController(IdentityService identityService, IdentityRsService identityRsService, ContactNotificationService contactNotificationService, IdenticonService identiconService)\n\t{\n\t\tthis.identityService = identityService;\n\t\tthis.identityRsService = identityRsService;\n\t\tthis.contactNotificationService = contactNotificationService;\n\t\tthis.identiconService = identiconService;\n\t}\n\n\t@GetMapping(\"/{id}\")\n\t@Operation(summary = \"Returns an identity\")\n\t@ApiResponse(responseCode = \"200\", description = \"Identity found\")\n\t@ApiResponse(responseCode = \"404\", description = \"Identity not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic IdentityDTO findIdentityById(@PathVariable long id)\n\t{\n\t\treturn toDTO(identityService.findById(id).orElseThrow());\n\t}\n\n\t@GetMapping(value = \"/{id}/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})\n\t@Operation(summary = \"Returns an identity's avatar image\")\n\t@ApiResponse(responseCode = \"200\", description = \"Identity's avatar image found\")\n\t@ApiResponse(responseCode = \"204\", description = \"Identity's avatar image is empty\")\n\t@ApiResponse(responseCode = \"404\", description = \"Identity not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<InputStreamResource> downloadIdentityImage(@PathVariable long id)\n\t{\n\t\tvar identity = identityService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype\n\t\tvar imageType = ImageUtils.getImageMimeType(identity.getImage());\n\t\tif (imageType == null)\n\t\t{\n\t\t\tvar image = identiconService.getIdenticon(identity.getGxsId().getBytes());\n\t\t\treturn ResponseEntity.ok()\n\t\t\t\t\t.contentLength(image.length)\n\t\t\t\t\t.contentType(ImageUtils.getImageMimeType(image))\n\t\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(image)));\n\t\t}\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(identity.getImage().length)\n\t\t\t\t.contentType(imageType)\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(identity.getImage())));\n\t}\n\n\t@GetMapping(value = \"/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})\n\t@Operation(summary = \"Returns an identity's image by GxsId (possibly autogenerated)\")\n\t@ApiResponse(responseCode = \"200\", description = \"Request successful\")\n\tpublic ResponseEntity<InputStreamResource> downloadImageByGxsId(@RequestParam(value = \"gxsId\") String gxsId, @RequestParam(value = \"find\", required = false) Boolean find)\n\t{\n\t\tbyte[] image = null;\n\n\t\tvar gxs = GxsId.fromString(gxsId);\n\n\t\tif (Boolean.TRUE.equals(find))\n\t\t{\n\t\t\tvar identity = identityService.findByGxsId(gxs).orElse(null);\n\t\t\tif (identity != null && ImageUtils.getImageMimeType(identity.getImage()) != null)\n\t\t\t{\n\t\t\t\timage = identity.getImage();\n\t\t\t}\n\t\t}\n\t\tif (image == null)\n\t\t{\n\t\t\timage = identiconService.getIdenticon(gxs.getBytes());\n\t\t}\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(image.length)\n\t\t\t\t.contentType(ImageUtils.getImageMimeType(image))\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(image)));\n\t}\n\n\n\t@PostMapping(value = \"/{id}/image\", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)\n\t@Operation(summary = \"Changes an identity's avatar image\")\n\t@ApiResponse(responseCode = \"201\", description = \"Identity's avatar image created\")\n\t@ApiResponse(responseCode = \"404\", description = \"Identity not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"415\", description = \"Image's media type unsupported\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"422\", description = \"Image unprocessable\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> uploadIdentityImage(@PathVariable long id, @RequestBody MultipartFile file) throws IOException\n\t{\n\t\tvar identity = identityRsService.saveOwnIdentityImage(id, file);\n\t\tcontactNotificationService.addOrUpdateIdentities(List.of(identity));\n\n\t\tvar location = ServletUriComponentsBuilder.fromCurrentRequest().replacePath(IDENTITIES_PATH + \"/{id}/image\").buildAndExpand(id).toUri();\n\t\treturn ResponseEntity.created(location).build();\n\t}\n\n\t@DeleteMapping(\"/{id}/image\")\n\t@Operation(summary = \"Removes an identity's image\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void deleteIdentityImage(@PathVariable long id)\n\t{\n\t\tvar identity = identityRsService.deleteOwnIdentityImage(id);\n\t\tcontactNotificationService.addOrUpdateIdentities(List.of(identity));\n\t}\n\n\t@GetMapping\n\t@Operation(summary = \"Searches all identities\", description = \"If no search parameters are provided, return all identities\")\n\tpublic List<IdentityDTO> findIdentities(\n\t\t\t@RequestParam(value = \"name\", required = false) String name,\n\t\t\t@RequestParam(value = \"gxsId\", required = false) String gxsId,\n\t\t\t@RequestParam(value = \"type\", required = false) Type type)\n\t{\n\t\tif (isNotBlank(name))\n\t\t{\n\t\t\treturn toDTOs(identityService.findAllByName(name));\n\t\t}\n\t\telse if (isNotBlank(gxsId))\n\t\t{\n\t\t\tvar identity = identityService.findByGxsId(GxsId.fromString(gxsId));\n\t\t\treturn identity.map(id -> List.of(toDTO(id))).orElse(Collections.emptyList());\n\t\t}\n\t\telse if (type != null)\n\t\t{\n\t\t\treturn toDTOs(identityService.findAllByType(type));\n\t\t}\n\t\treturn toDTOs(identityService.getAll());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/location/LocationController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.location;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.QrCodeService;\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.rest.location.RSIdResponse;\nimport io.xeres.common.rsid.Type;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport java.awt.image.BufferedImage;\n\nimport static io.xeres.app.database.model.location.LocationMapper.toDTO;\nimport static io.xeres.common.rest.PathConfig.LOCATIONS_PATH;\n\n@Tag(name = \"Location\", description = \"Local instance\")\n@RestController\n@RequestMapping(value = LOCATIONS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class LocationController\n{\n\tprivate final LocationService locationService;\n\n\tprivate final QrCodeService qrCodeService;\n\n\tpublic LocationController(LocationService locationService, QrCodeService qrCodeService)\n\t{\n\t\tthis.locationService = locationService;\n\t\tthis.qrCodeService = qrCodeService;\n\t}\n\n\t@GetMapping(\"/{id}\")\n\t@Operation(summary = \"Returns a location\")\n\t@ApiResponse(responseCode = \"200\", description = \"Location found\")\n\t@ApiResponse(responseCode = \"404\", description = \"Location not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic LocationDTO findLocationById(@PathVariable long id)\n\t{\n\t\treturn toDTO(locationService.findLocationById(id).orElseThrow());\n\t}\n\n\t@GetMapping(\"/{id}/rs-id\")\n\t@Operation(summary = \"Returns a location's RSId\")\n\t@ApiResponse(responseCode = \"200\", description = \"Location found\")\n\t@ApiResponse(responseCode = \"404\", description = \"Profile not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic RSIdResponse getRSIdOfLocation(@PathVariable long id, @RequestParam(value = \"type\", required = false) Type type)\n\t{\n\t\tvar location = locationService.findLocationById(id).orElseThrow();\n\n\t\treturn new RSIdResponse(location.getProfile().getName(), location.getSafeName(), location.getRsId(type == null ? Type.ANY : type).getArmored());\n\t}\n\n\t@GetMapping(value = \"/{id}/rs-id/qr-code\", produces = MediaType.IMAGE_PNG_VALUE)\n\t@Operation(summary = \"Returns a location's RSId as a QR code\")\n\t@ApiResponse(responseCode = \"200\", description = \"Location found\")\n\t@ApiResponse(responseCode = \"404\", description = \"Profile not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<BufferedImage> getRSIdOfLocationAsQrCode(@PathVariable long id)\n\t{\n\t\tvar location = locationService.findLocationById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype\n\n\t\treturn ResponseEntity.ok(qrCodeService.generateQrCode(location.getRsId(Type.SHORT_INVITE).getArmored()));\n\t}\n\n\t@GetMapping(\"/{id}/service/{serviceId}\")\n\t@Operation(summary = \"Returns if a location supports a service\")\n\t@ApiResponse(responseCode = \"200\", description = \"Service supported\")\n\t@ApiResponse(responseCode = \"404\", description = \"Service not supported\")\n\tpublic ResponseEntity<Void> isServiceSupported(@PathVariable long id, @PathVariable int serviceId)\n\t{\n\t\tvar location = locationService.findLocationById(id).orElseThrow();\n\t\tvar supported = locationService.isServiceSupported(location, serviceId);\n\n\t\tif (supported)\n\t\t{\n\t\t\treturn ResponseEntity.ok().build();\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn ResponseEntity.notFound().build();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/notification/NotificationController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.notification;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.notification.availability.AvailabilityNotificationService;\nimport io.xeres.app.service.notification.board.BoardNotificationService;\nimport io.xeres.app.service.notification.channel.ChannelNotificationService;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.app.service.notification.file.FileNotificationService;\nimport io.xeres.app.service.notification.file.FileSearchNotificationService;\nimport io.xeres.app.service.notification.file.FileTrendNotificationService;\nimport io.xeres.app.service.notification.forum.ForumNotificationService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.servlet.mvc.method.annotation.SseEmitter;\n\nimport static io.xeres.common.rest.PathConfig.NOTIFICATIONS_PATH;\n\n@Tag(name = \"Notification\", description = \"Out of band notifications\")\n@RestController\n@RequestMapping(value = NOTIFICATIONS_PATH, produces = MediaType.TEXT_EVENT_STREAM_VALUE)\npublic class NotificationController\n{\n\tprivate final StatusNotificationService statusNotificationService;\n\tprivate final ForumNotificationService forumNotificationService;\n\tprivate final FileNotificationService fileNotificationService;\n\tprivate final FileSearchNotificationService fileSearchNotificationService;\n\tprivate final FileTrendNotificationService fileTrendNotificationService;\n\tprivate final ContactNotificationService contactNotificationService;\n\tprivate final AvailabilityNotificationService availabilityNotificationService;\n\tprivate final BoardNotificationService boardNotificationService;\n\tprivate final ChannelNotificationService channelNotificationService;\n\n\tpublic NotificationController(StatusNotificationService statusNotificationService, ForumNotificationService forumNotificationService, FileNotificationService fileNotificationService, FileSearchNotificationService fileSearchNotificationService, FileTrendNotificationService fileTrendNotificationService, ContactNotificationService contactNotificationService, AvailabilityNotificationService availabilityNotificationService, BoardNotificationService boardNotificationService, ChannelNotificationService channelNotificationService)\n\t{\n\t\tthis.statusNotificationService = statusNotificationService;\n\t\tthis.forumNotificationService = forumNotificationService;\n\t\tthis.fileNotificationService = fileNotificationService;\n\t\tthis.fileSearchNotificationService = fileSearchNotificationService;\n\t\tthis.fileTrendNotificationService = fileTrendNotificationService;\n\t\tthis.contactNotificationService = contactNotificationService;\n\t\tthis.availabilityNotificationService = availabilityNotificationService;\n\t\tthis.boardNotificationService = boardNotificationService;\n\t\tthis.channelNotificationService = channelNotificationService;\n\t}\n\n\t@GetMapping(\"/status\")\n\t@Operation(summary = \"Subscribes to status notifications\")\n\tpublic SseEmitter setupStatusNotification()\n\t{\n\t\treturn statusNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/forum\")\n\t@Operation(summary = \"Subscribes to forum notifications\")\n\tpublic SseEmitter setupForumNotification()\n\t{\n\t\treturn forumNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/board\")\n\t@Operation(summary = \"Subscribes to board notifications\")\n\tpublic SseEmitter setupBoardNotification()\n\t{\n\t\treturn boardNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/channel\")\n\t@Operation(summary = \"Subscribes to channel notifications\")\n\tpublic SseEmitter setupChannelNotification()\n\t{\n\t\treturn channelNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/file\")\n\t@Operation(summary = \"Subscribes to file notifications\")\n\tpublic SseEmitter setupFileNotification()\n\t{\n\t\treturn fileNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/file-search\")\n\t@Operation(summary = \"Subscribes to file search notifications\")\n\tpublic SseEmitter setupFileSearchNotification()\n\t{\n\t\treturn fileSearchNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/file-trend\")\n\t@Operation(summary = \"Subscribes to file trend notifications\")\n\tpublic SseEmitter setupFileTrendNotification()\n\t{\n\t\treturn fileTrendNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/contact\")\n\t@Operation(summary = \"Subscribes to contact notifications\")\n\tpublic SseEmitter setupContactNotification()\n\t{\n\t\treturn contactNotificationService.addClient();\n\t}\n\n\t@GetMapping(\"/availability\")\n\t@Operation(summary = \"Subscribes to connection notifications\")\n\tpublic SseEmitter setupConnectionNotification()\n\t{\n\t\treturn availabilityNotificationService.addClient();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/profile/ProfileController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.profile;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.api.exception.UnprocessableEntityException;\nimport io.xeres.app.crypto.rsid.RSId;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.job.PeerConnectionJob;\nimport io.xeres.app.service.ContactService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.identicon.IdenticonService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.rest.profile.ProfileKeyAttributes;\nimport io.xeres.common.rest.profile.RsIdRequest;\nimport io.xeres.common.util.image.ImageUtils;\nimport jakarta.validation.Valid;\nimport org.springframework.core.io.InputStreamResource;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.servlet.support.ServletUriComponentsBuilder;\n\nimport java.io.ByteArrayInputStream;\nimport java.nio.ByteBuffer;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static io.xeres.app.database.model.profile.ProfileMapper.*;\nimport static io.xeres.common.rest.PathConfig.PROFILES_PATH;\nimport static io.xeres.common.rsid.Type.ANY;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Tag(name = \"Profile\", description = \"User's profiles\")\n@RestController\n@RequestMapping(value = PROFILES_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ProfileController\n{\n\tprivate final ProfileService profileService;\n\tprivate final IdentityService identityService;\n\tprivate final LocationService locationService;\n\tprivate final ContactService contactService;\n\n\tprivate final PeerConnectionJob peerConnectionJob;\n\tprivate final StatusNotificationService statusNotificationService;\n\tprivate final IdenticonService identiconService;\n\n\tpublic ProfileController(ProfileService profileService, IdentityService identityService, LocationService locationService, ContactService contactService, PeerConnectionJob peerConnectionJob, StatusNotificationService statusNotificationService, IdenticonService identiconService)\n\t{\n\t\tthis.profileService = profileService;\n\t\tthis.identityService = identityService;\n\t\tthis.locationService = locationService;\n\t\tthis.contactService = contactService;\n\t\tthis.peerConnectionJob = peerConnectionJob;\n\t\tthis.statusNotificationService = statusNotificationService;\n\t\tthis.identiconService = identiconService;\n\t}\n\n\t@GetMapping(\"/{id}\")\n\t@Operation(summary = \"Returns a profile\")\n\t@ApiResponse(responseCode = \"200\", description = \"Profile found\")\n\t@ApiResponse(responseCode = \"404\", description = \"Profile not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ProfileDTO findProfileById(@PathVariable long id)\n\t{\n\t\treturn toDeepDTO(profileService.findProfileById(id).orElseThrow());\n\t}\n\n\t@GetMapping(\"/{id}/key-attributes\")\n\t@Operation(summary = \"Returns the profile's key attributes\")\n\t@ApiResponse(responseCode = \"200\", description = \"Profile found\")\n\t@ApiResponse(responseCode = \"400\", description = \"Error in the profile's key\")\n\t@ApiResponse(responseCode = \"404\", description = \"Profile not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ProfileKeyAttributes findProfileKeyAttributes(@PathVariable long id)\n\t{\n\t\treturn profileService.findProfileKeyAttributes(id);\n\t}\n\n\t@GetMapping(\"/{id}/contacts\")\n\t@Operation(summary = \"Returns the profile's identities as contacts\")\n\tpublic List<Contact> findContactsForProfile(@PathVariable long id)\n\t{\n\t\treturn contactService.getContactsForProfileId(id);\n\t}\n\n\t@GetMapping(value = \"/{id}/image\", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})\n\t@Operation(summary = \"Returns a profile's avatar image (currently an identicon)\")\n\t@ApiResponse(responseCode = \"200\", description = \"Profile's avatar image found\")\n\t@ApiResponse(responseCode = \"404\", description = \"Profile not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<InputStreamResource> downloadImage(@PathVariable long id)\n\t{\n\t\tvar profile = profileService.findProfileById(id).orElseThrow();\n\t\tvar image = identiconService.getIdenticon(ByteBuffer.wrap(new byte[8]).putLong(profile.getPgpIdentifier()).array());\n\t\treturn ResponseEntity.ok()\n\t\t\t\t.contentLength(image.length)\n\t\t\t\t.contentType(ImageUtils.getImageMimeType(image))\n\t\t\t\t.body(new InputStreamResource(new ByteArrayInputStream(image)));\n\t}\n\n\t@GetMapping\n\t@Operation(summary = \"Searches all profiles\", description = \"If no search parameters are provided, return all profiles\")\n\t@ApiResponse(responseCode = \"200\", description = \"All matched profiles\")\n\tpublic List<ProfileDTO> findProfiles(@RequestParam(value = \"name\", required = false) String name,\n\t                                     @RequestParam(value = \"locationIdentifier\", required = false) String locationIdentifierString,\n\t                                     @RequestParam(value = \"pgpIdentifier\", required = false) String pgpIdentifierString,\n\t                                     @RequestParam(value = \"withLocations\", required = false) Boolean withLocations)\n\t{\n\t\tif (isNotBlank(name))\n\t\t{\n\t\t\treturn toDTOs(profileService.findProfilesByName(name));\n\t\t}\n\t\telse if (isNotBlank(locationIdentifierString))\n\t\t{\n\t\t\tvar locationIdentifier = LocationIdentifier.fromString(locationIdentifierString);\n\t\t\tvar profile = profileService.findProfileByLocationIdentifier(locationIdentifier);\n\t\t\treturn profile.map(p -> List.of(Boolean.TRUE.equals(withLocations) ? toDeepDTO(p, locationIdentifier) : toDTO(p))).orElse(Collections.emptyList());\n\t\t}\n\t\telse if (isNotBlank(pgpIdentifierString))\n\t\t{\n\t\t\tvar profile = profileService.findProfileByPgpIdentifier(Long.parseUnsignedLong(pgpIdentifierString, 16));\n\t\t\treturn profile.map(p -> List.of(Boolean.TRUE.equals(withLocations) ? toDeepDTO(p) : toDTO(p))).orElse(Collections.emptyList());\n\t\t}\n\t\treturn toDTOs(profileService.getAllProfiles());\n\t}\n\n\t@PostMapping\n\t@Operation(summary = \"Creates a profile and its possible location from an RS ID\")\n\t@ApiResponse(responseCode = \"201\", description = \"Profile created successfully\", headers = @Header(name = \"location\", description = \"the location of the profile\"))\n\t@ApiResponse(responseCode = \"422\", description = \"Profile entity cannot be processed\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"500\", description = \"Serious error\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> createProfileFromRsId(@Valid @RequestBody RsIdRequest rsIdRequest,\n\t                                                  @RequestParam(value = \"connectionIndex\", required = false) Integer connectionIndex,\n\t                                                  @RequestParam(value = \"trust\", required = false) Trust trust)\n\t{\n\t\tvar profile = profileService.getProfileFromRSId(RSId.parse(rsIdRequest.rsId(), ANY).orElseThrow(() -> new UnprocessableEntityException(\"RS id is invalid\")));\n\t\tvar locationToConnectTo = profile.getLocations().stream().findFirst();\n\n\t\tif (trust != null)\n\t\t{\n\t\t\tif (trust == Trust.ULTIMATE)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"ULTIMATE trust cannot be set\");\n\t\t\t}\n\t\t\tprofile.setTrust(trust);\n\t\t}\n\n\t\tvar savedProfile = profileService.createOrUpdateProfile(profile);\n\n\t\tstatusNotificationService.setTotalUsers((int) locationService.countLocations());\n\n\t\tlocationToConnectTo.ifPresent(location ->\n\t\t{\n\t\t\tif (connectionIndex != null && connectionIndex >= 0)\n\t\t\t{\n\t\t\t\tpeerConnectionJob.connectImmediately(location, connectionIndex);\n\t\t\t}\n\t\t});\n\n\t\tvar profileLocation = ServletUriComponentsBuilder.fromCurrentRequest()\n\t\t\t\t.path(\"/{id}\")\n\t\t\t\t.replaceQuery(null)\n\t\t\t\t.buildAndExpand(savedProfile.getId()).toUri();\n\t\treturn ResponseEntity.created(profileLocation).build();\n\t}\n\n\t@PostMapping(\"/check\")\n\t@Operation(summary = \"Checks an RS ID\")\n\t@ApiResponse(responseCode = \"200\", description = \"RS ID is OK\")\n\t@ApiResponse(responseCode = \"422\", description = \"RS ID cannot be processed\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"500\", description = \"Serious error\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ProfileDTO checkProfileFromRsId(@Valid @RequestBody RsIdRequest rsIdRequest)\n\t{\n\t\tvar rsId = RSId.parse(rsIdRequest.rsId(), ANY).orElseThrow(() -> new UnprocessableEntityException(\"RS id is invalid\"));\n\t\treturn toDeepDTO(profileService.getProfileFromRSId(rsId));\n\t}\n\n\t@PutMapping(\"/{id}/trust\")\n\t@Operation(summary = \"Sets the trust of a profile\")\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void setTrust(@PathVariable long id, @RequestBody Trust trust)\n\t{\n\t\tvar profile = profileService.findProfileById(id).orElseThrow(() -> new UnprocessableEntityException(\"Profile not found\"));\n\t\tif (profile.isOwn())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot change the trust of own profile\");\n\t\t}\n\t\tif (trust == Trust.ULTIMATE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"ULTIMATE trust cannot be set\");\n\t\t}\n\t\tprofile.setTrust(trust);\n\n\t\tprofileService.createOrUpdateProfile(profile);\n\t}\n\n\t@DeleteMapping(\"/{id}\")\n\t@Operation(summary = \"Deletes a profile\")\n\t@ApiResponse(responseCode = \"200\", description = \"Profile successfully deleted\")\n\t@ApiResponse(responseCode = \"404\", description = \"Profile not found\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ResponseStatus(HttpStatus.NO_CONTENT)\n\tpublic void deleteProfile(@PathVariable long id)\n\t{\n\t\tif (Profile.isOwn(id))\n\t\t{\n\t\t\tthrow new UnprocessableEntityException(\"The main profile cannot be deleted\");\n\t\t}\n\t\tidentityService.removeAllLinksToProfile(id);\n\t\tprofileService.deleteProfile(id);\n\n\t\tstatusNotificationService.setTotalUsers((int) locationService.countLocations());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/settings/SettingsController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.settings;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.common.dto.settings.SettingsDTO;\nimport jakarta.json.JsonPatch;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n\nimport static io.xeres.app.database.model.settings.SettingsMapper.fromDTO;\nimport static io.xeres.app.database.model.settings.SettingsMapper.toDTO;\nimport static io.xeres.common.rest.PathConfig.SETTINGS_PATH;\n\n@Tag(name = \"Settings\", description = \"Persisted settings\")\n@RestController\n@RequestMapping(value = SETTINGS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class SettingsController\n{\n\tprivate final SettingsService settingsService;\n\n\tpublic SettingsController(SettingsService settingsService)\n\t{\n\t\tthis.settingsService = settingsService;\n\t}\n\n\t@GetMapping\n\t@Operation(summary = \"Gets the current settings\")\n\tpublic SettingsDTO getSettings()\n\t{\n\t\treturn settingsService.getSettings();\n\t}\n\n\t@PatchMapping(consumes = \"application/json-patch+json\")\n\t@Operation(summary = \"Updates the settings\")\n\tpublic ResponseEntity<SettingsDTO> updateSettings(@RequestBody JsonPatch jsonPatch)\n\t{\n\t\tvar newSettings = settingsService.applyPatchToSettings(jsonPatch);\n\t\treturn ResponseEntity.ok(toDTO(newSettings));\n\t}\n\n\t@PutMapping\n\t@Operation(summary = \"Updates the settings\")\n\tpublic ResponseEntity<SettingsDTO> updateSettings(@RequestBody SettingsDTO settingsDTO)\n\t{\n\t\tvar newSettings = settingsService.applySettings(fromDTO(settingsDTO));\n\t\treturn ResponseEntity.ok(toDTO(newSettings));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/share/ShareController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.share;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.api.exception.InternalServerErrorException;\nimport io.xeres.app.service.file.FileService;\nimport io.xeres.common.dto.share.ShareDTO;\nimport io.xeres.common.rest.share.TemporaryShareRequest;\nimport io.xeres.common.rest.share.TemporaryShareResponse;\nimport io.xeres.common.rest.share.UpdateShareRequest;\nimport jakarta.validation.Valid;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.ErrorResponse;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.nio.file.Paths;\nimport java.util.List;\n\nimport static io.xeres.app.database.model.share.ShareMapper.fromDTOs;\nimport static io.xeres.app.database.model.share.ShareMapper.toDTOs;\nimport static io.xeres.common.rest.PathConfig.SHARES_PATH;\n\n@Tag(name = \"Share\", description = \"File shares\")\n@RestController\n@RequestMapping(value = SHARES_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class ShareController\n{\n\tprivate final FileService fileService;\n\n\tpublic ShareController(FileService fileService)\n\t{\n\t\tthis.fileService = fileService;\n\t}\n\n\t@GetMapping\n\t@Operation(summary = \"Returns all shares\", description = \"Return all configured shares\")\n\t@ApiResponse(responseCode = \"200\", description = \"All shares\")\n\tpublic List<ShareDTO> getShares()\n\t{\n\t\tvar shares = fileService.getShares();\n\t\treturn toDTOs(shares, fileService.getFilesMapFromShares(shares));\n\t}\n\n\t@PostMapping\n\t@Operation(summary = \"Adds/Updates shares\")\n\t@ApiResponse(responseCode = \"201\", description = \"Shares created/updated successfully\")\n\t@ApiResponse(responseCode = \"422\", description = \"Shares cannot be processed\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\t@ApiResponse(responseCode = \"500\", description = \"Serious error\", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))\n\tpublic ResponseEntity<Void> createAndUpdateShares(@Valid @RequestBody UpdateShareRequest updateSharesRequest)\n\t{\n\t\tfileService.synchronize(fromDTOs(updateSharesRequest.shares()));\n\t\treturn ResponseEntity.status(HttpStatus.CREATED).build();\n\t}\n\n\t@PostMapping(\"/temporary\")\n\t@Operation(summary = \"Adds a file to share temporarily\")\n\t@ApiResponse(responseCode = \"200\", description = \"File added to temporary share successfully\")\n\tpublic TemporaryShareResponse shareTemporarily(@Valid @RequestBody TemporaryShareRequest temporaryShareRequest)\n\t{\n\t\t//noinspection JvmTaintAnalysis\n\t\tvar path = Paths.get(temporaryShareRequest.filePath());\n\t\tvar hash = fileService.calculateTemporaryFileHash(path);\n\n\t\tif (hash == null)\n\t\t{\n\t\t\tthrow new InternalServerErrorException(\"Cannot compute hash of file\");\n\t\t}\n\t\treturn new TemporaryShareResponse(hash.toString());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/statistics/StatisticsController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.statistics;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport io.xeres.app.xrs.service.bandwidth.BandwidthRsService;\nimport io.xeres.app.xrs.service.rtt.RttRsService;\nimport io.xeres.app.xrs.service.turtle.TurtleRsService;\nimport io.xeres.common.rest.statistics.DataCounterStatisticsResponse;\nimport io.xeres.common.rest.statistics.RttStatisticsResponse;\nimport io.xeres.common.rest.statistics.TurtleStatisticsResponse;\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport static io.xeres.app.api.controller.statistics.StatisticsMapper.toDTO;\nimport static io.xeres.common.rest.PathConfig.STATISTICS_PATH;\n\n@Tag(name = \"Statistics\", description = \"Statistics service\")\n@RestController\n@RequestMapping(value = STATISTICS_PATH, produces = MediaType.APPLICATION_JSON_VALUE)\npublic class StatisticsController\n{\n\tprivate final TurtleRsService turtleRsService;\n\tprivate final RttRsService rttRsService;\n\tprivate final BandwidthRsService bandwidthRsService;\n\n\tpublic StatisticsController(TurtleRsService turtleRsService, RttRsService rttRsService, BandwidthRsService bandwidthRsService)\n\t{\n\t\tthis.turtleRsService = turtleRsService;\n\t\tthis.rttRsService = rttRsService;\n\t\tthis.bandwidthRsService = bandwidthRsService;\n\t}\n\n\t@GetMapping(\"/turtle\")\n\t@Operation(summary = \"Gets turtle statistics\")\n\tpublic TurtleStatisticsResponse getTurtleStatistics()\n\t{\n\t\treturn toDTO(turtleRsService.getStatistics());\n\t}\n\n\t@GetMapping(\"/rtt\")\n\t@Operation(summary = \"Gets RTT statistics\")\n\tpublic RttStatisticsResponse getRttStatistics()\n\t{\n\t\treturn rttRsService.getStatistics();\n\t}\n\n\t@GetMapping(\"/data-counter\")\n\t@Operation(summary = \"Gets global data counter statistics\")\n\tpublic DataCounterStatisticsResponse getDataCounterStatistics()\n\t{\n\t\treturn bandwidthRsService.getDataCounterStatistics();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/statistics/StatisticsMapper.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.statistics;\n\nimport io.xeres.app.xrs.service.turtle.TurtleStatistics;\nimport io.xeres.common.rest.statistics.TurtleStatisticsResponse;\n\nfinal class StatisticsMapper\n{\n\tprivate StatisticsMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static TurtleStatisticsResponse toDTO(TurtleStatistics turtleStatistics)\n\t{\n\t\tif (turtleStatistics == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new TurtleStatisticsResponse(\n\t\t\t\tturtleStatistics.getForwardTotal(),\n\t\t\t\tturtleStatistics.getDataUpload(),\n\t\t\t\tturtleStatistics.getDataDownload(),\n\t\t\t\tturtleStatistics.getTunnelRequestsUpload(),\n\t\t\t\tturtleStatistics.getTunnelRequestsDownload(),\n\t\t\t\tturtleStatistics.getSearchRequestsUpload(),\n\t\t\t\tturtleStatistics.getSearchRequestsDownload(),\n\t\t\t\tturtleStatistics.getTotalUpload(),\n\t\t\t\tturtleStatistics.getTotalDownload()\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/controller/voip/VoipMessageController.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.voip;\n\nimport io.xeres.app.xrs.service.voip.VoipRsService;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.voip.VoipAction;\nimport io.xeres.common.message.voip.VoipMessage;\nimport jakarta.validation.Valid;\nimport org.springframework.messaging.handler.annotation.Header;\nimport org.springframework.messaging.handler.annotation.MessageMapping;\nimport org.springframework.messaging.handler.annotation.Payload;\nimport org.springframework.stereotype.Controller;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessagePath.VOIP_PRIVATE_DESTINATION;\nimport static io.xeres.common.message.MessagePath.VOIP_ROOT;\n\n@Controller\n@MessageMapping(VOIP_ROOT)\npublic class VoipMessageController\n{\n\tprivate final VoipRsService voipRsService;\n\n\tpublic VoipMessageController(VoipRsService voipRsService)\n\t{\n\t\tthis.voipRsService = voipRsService;\n\t}\n\n\t@MessageMapping(VOIP_PRIVATE_DESTINATION)\n\tpublic void processPrivateVoipMessageFromProducer(@Header(DESTINATION_ID) String destinationId, @Payload @Valid VoipMessage voipMessage)\n\t{\n\t\tvar locationIdentifier = LocationIdentifier.fromString(destinationId);\n\n\t\tswitch (voipMessage.getAction())\n\t\t{\n\t\t\tcase VoipAction.RING -> voipRsService.call(locationIdentifier);\n\t\t\tcase VoipAction.ACKNOWLEDGE -> voipRsService.accept(locationIdentifier);\n\t\t\tcase VoipAction.CLOSE -> voipRsService.hangup(locationIdentifier);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/converter/BufferedImageConverter.java",
    "content": "package io.xeres.app.api.converter;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.converter.BufferedImageHttpMessageConverter;\nimport org.springframework.http.converter.HttpMessageConverter;\nimport org.springframework.stereotype.Component;\n\nimport java.awt.image.BufferedImage;\n\n@Component\npublic class BufferedImageConverter\n{\n\t@Bean\n\tpublic HttpMessageConverter<BufferedImage> createBufferedImageHttpMessageConverter()\n\t{\n\t\treturn new BufferedImageHttpMessageConverter();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/exception/InternalServerErrorException.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.exception;\n\nimport java.io.Serial;\n\npublic class InternalServerErrorException extends RuntimeException\n{\n\t@Serial\n\tprivate static final long serialVersionUID = 371250063985938335L;\n\n\tpublic InternalServerErrorException(String message)\n\t{\n\t\tsuper(message);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/exception/UnprocessableEntityException.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.exception;\n\nimport java.io.Serial;\n\npublic class UnprocessableEntityException extends RuntimeException\n{\n\t@Serial\n\tprivate static final long serialVersionUID = -889467114836196650L;\n\n\tpublic UnprocessableEntityException(String message)\n\t{\n\t\tsuper(message);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/api/package-info.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * REST API.\n */\npackage io.xeres.app.api;"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/SingleInstanceRun.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application;\n\n\nimport io.xeres.common.AppName;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.io.RandomAccessFile;\nimport java.nio.channels.FileLock;\nimport java.nio.file.Files;\nimport java.util.Locale;\nimport java.util.Optional;\n\n/**\n * Utility class to detect if an application is already running.\n */\npublic final class SingleInstanceRun\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SingleInstanceRun.class);\n\n\tprivate static final String LOCK_FILE_NAME = \".\" + AppName.NAME.toLowerCase(Locale.ROOT) + \".lock\";\n\n\tprivate static File file;\n\tprivate static RandomAccessFile randomAccessFile;\n\tprivate static FileLock lock;\n\n\tprivate SingleInstanceRun()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Enforces an application to have a single instance of itself, given a certain directory.\n\t *\n\t * @param dataDir the directory to be used by the application. If it's null, no enforcing is performed and\n\t *                true is returned because there's no data dir to conflict with\n\t * @return true if the application can run without conflicts; false if it's already running\n\t */\n\tpublic static boolean enforceSingleInstance(String dataDir)\n\t{\n\t\tif (dataDir == null)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\n\t\tfile = new File(dataDir, LOCK_FILE_NAME);\n\n\t\tvar result = false;\n\t\ttry\n\t\t{\n\t\t\trandomAccessFile = new RandomAccessFile(file, \"rw\");\n\n\t\t\tlock = Optional.ofNullable(randomAccessFile.getChannel().tryLock()).orElseThrow(() -> new IllegalStateException(\"Lock already acquired by another process\"));\n\t\t\tresult = true;\n\t\t\tRuntime.getRuntime().addShutdownHook(Thread.ofVirtual().unstarted(new ShutdownHook()));\n\t\t}\n\t\tcatch (IOException | IllegalStateException | IllegalArgumentException e)\n\t\t{\n\t\t\tlog.debug(\"Couldn't enforce single instance: {}.\", e.getMessage());\n\t\t}\n\t\tcatch (SecurityException _)\n\t\t{\n\t\t\tlog.warn(\"Shutdown hook denied by SecurityManager; There will be a dangling lock file at {}\", LOCK_FILE_NAME);\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate static class ShutdownHook implements Runnable\n\t{\n\t\t@Override\n\t\tpublic void run()\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tlock.release();\n\t\t\t\trandomAccessFile.close();\n\t\t\t\tFiles.delete(file.toPath());\n\t\t\t}\n\t\t\tcatch (IOException | SecurityException _)\n\t\t\t{\n\t\t\t\t// No logging in the shutdown hook because logback also uses one to clean up\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/Startup.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application;\n\nimport io.xeres.app.application.autostart.AutoStart;\nimport io.xeres.app.application.events.LocationReadyEvent;\nimport io.xeres.app.application.events.SettingsChangedEvent;\nimport io.xeres.app.configuration.DataDirConfiguration;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.settings.Settings;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.*;\nimport io.xeres.app.service.UiBridgeService.SplashStatus;\nimport io.xeres.app.service.notification.file.FileNotificationService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.app.service.shell.ShellService;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.common.events.ConnectWebSocketsEvent;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.mui.MUI;\nimport io.xeres.common.util.RemoteUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.ApplicationArguments;\nimport org.springframework.boot.ApplicationRunner;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\n\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\n\n@Component\npublic class Startup implements ApplicationRunner\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Startup.class);\n\n\t/**\n\t * Minimum time to run before doing a backup. This avoids making useless\n\t * backups when performing tests.\n\t */\n\tpublic static final Duration BACKUP_UPTIME = Duration.ofMinutes(5);\n\n\tprivate final LocationService locationService;\n\tprivate final SettingsService settingsService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final DataDirConfiguration dataDirConfiguration;\n\tprivate final NetworkService networkService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final UiBridgeService uiBridgeService;\n\tprivate final IdentityManager identityManager;\n\tprivate final StatusNotificationService statusNotificationService;\n\tprivate final AutoStart autoStart;\n\tprivate final ShellService shellService;\n\tprivate final FileNotificationService fileNotificationService;\n\tprivate final InfoService infoService;\n\tprivate final UpgradeService upgradeService;\n\tprivate final ApplicationEventPublisher publisher;\n\tpublic Startup(LocationService locationService, SettingsService settingsService, DatabaseSessionManager databaseSessionManager, DataDirConfiguration dataDirConfiguration, NetworkService networkService, PeerConnectionManager peerConnectionManager, UiBridgeService uiBridgeService, IdentityManager identityManager, StatusNotificationService statusNotificationService, AutoStart autoStart, ShellService shellService, FileNotificationService fileNotificationService, InfoService infoService, UpgradeService upgradeService, ApplicationEventPublisher publisher)\n\t{\n\t\tthis.locationService = locationService;\n\t\tthis.settingsService = settingsService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.dataDirConfiguration = dataDirConfiguration;\n\t\tthis.networkService = networkService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\tthis.identityManager = identityManager;\n\t\tthis.statusNotificationService = statusNotificationService;\n\t\tthis.autoStart = autoStart;\n\t\tthis.shellService = shellService;\n\t\tthis.fileNotificationService = fileNotificationService;\n\t\tthis.infoService = infoService;\n\t\tthis.upgradeService = upgradeService;\n\t\tthis.publisher = publisher;\n\t}\n\n\t@Override\n\tpublic void run(ApplicationArguments args)\n\t{\n\t\t// This is a convenient place to start code as it works in both UI and non-UI mode\n\t\tinfoService.showStartupInfo();\n\t\tcheckRequirements();\n\t\tinfoService.showCapabilities();\n\t\tinfoService.showFeatures();\n\t\tinfoService.showDebug();\n\n\t\tpublisher.publishEvent(new StartupEvent());    // This is synchronous and allows WebClients to configure themselves.\n\n\t\tif (RemoteUtils.isRemoteUiClient())\n\t\t{\n\t\t\tlog.info(\"Remote UI mode\");\n\t\t\tpublisher.publishEvent(new ConnectWebSocketsEvent()); // Make sure the websockets connect\n\t\t\treturn;\n\t\t}\n\n\t\tupgradeService.upgrade();\n\n\t\tif (networkService.checkReadiness())\n\t\t{\n\t\t\tuiBridgeService.setSplashStatus(SplashStatus.NETWORK);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.info(\"Waiting... Use the user interface to send commands to create a profile\");\n\t\t\tuiBridgeService.closeSplashScreen();\n\t\t}\n\t}\n\n\t/**\n\t * Called when the application setup is ready (aka we have a location).\n\t *\n\t * @param ignoredEvent the {@link LocationReadyEvent}\n\t */\n\t@EventListener\n\tpublic void onApplicationEvent(LocationReadyEvent ignoredEvent)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tsyncAutoStart();\n\t\t\tstatusNotificationService.setTotalUsers((int) locationService.countLocations());\n\t\t\tnetworkService.start();\n\t\t}\n\t\tMUI.setShell(shellService);\n\t\tuiBridgeService.closeSplashScreen();\n\t}\n\n\t@EventListener\n\tpublic void onSettingsChangedEvent(SettingsChangedEvent event)\n\t{\n\t\tcompareSettingsAndApplyActions(event.oldSettings(), event.newSettings());\n\t}\n\n\t@EventListener // We don't use @PreDestroy because netty uses other beans on shutdown, and we don't want them in shutdown state already\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tbackupUserData();\n\n\t\tlog.info(\"Shutting down...\");\n\t\tidentityManager.shutdown();\n\t\tpeerConnectionManager.shutdown();\n\n\t\tstatusNotificationService.shutdown();\n\t\tfileNotificationService.shutdown();\n\n\t\tnetworkService.stop();\n\t}\n\n\tprivate void backupUserData()\n\t{\n\t\tif (dataDirConfiguration.getDataDir() != null && infoService.getUptime().compareTo(BACKUP_UPTIME) > 0) // Don't back up the database when running unit tests, and not if we run for not enough time\n\t\t{\n\t\t\tsettingsService.backup(dataDirConfiguration.getDataDir());\n\t\t}\n\t}\n\n\tprivate static void checkRequirements()\n\t{\n\t\tif (Charset.defaultCharset() != StandardCharsets.UTF_8)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Platform charset must be UTF-8, found: \" + Charset.defaultCharset());\n\t\t}\n\t}\n\n\tprivate void compareSettingsAndApplyActions(Settings oldSettings, Settings newSettings)\n\t{\n\t\tnetworkService.compareSettingsAndApplyActions(oldSettings, newSettings);\n\t\tapplyAutoStart(oldSettings, newSettings);\n\t}\n\n\tprivate void applyAutoStart(Settings oldSettings, Settings newSettings)\n\t{\n\t\tif (newSettings.isAutoStartEnabled() != oldSettings.isAutoStartEnabled())\n\t\t{\n\t\t\tif (newSettings.isAutoStartEnabled())\n\t\t\t{\n\t\t\t\tautoStart.enable();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tautoStart.disable();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void syncAutoStart()\n\t{\n\t\tif (settingsService.isAutoStartEnabled() != autoStart.isEnabled())\n\t\t{\n\t\t\tlog.info(\"Autostart is desynced, forcing to {}\", settingsService.isAutoStartEnabled());\n\t\t\tif (settingsService.isAutoStartEnabled())\n\t\t\t{\n\t\t\t\tautoStart.enable();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tautoStart.disable();\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/autostart/AutoStart.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.autostart;\n\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class AutoStart\n{\n\tprivate final AutoStarter autoStarter;\n\n\tpublic AutoStart(AutoStarter autoStarter)\n\t{\n\t\tthis.autoStarter = autoStarter;\n\t}\n\n\tpublic boolean isSupported()\n\t{\n\t\treturn autoStarter.isSupported();\n\t}\n\n\tpublic boolean isEnabled()\n\t{\n\t\tif (!isSupported())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\treturn autoStarter.isEnabled();\n\t}\n\n\tpublic void enable()\n\t{\n\t\tif (isSupported())\n\t\t{\n\t\t\tautoStarter.enable();\n\t\t}\n\t}\n\n\tpublic void disable()\n\t{\n\t\tif (isSupported())\n\t\t{\n\t\t\tautoStarter.disable();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/autostart/AutoStarter.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.autostart;\n\npublic interface AutoStarter\n{\n\t/**\n\t * Checks if the auto start feature is supported by the system.\n\t * <p>\n\t * Usually depends on the host OS and installation mode (for example, portable mode doesn't support auto start).\n\t *\n\t * @return true if auto start is supported\n\t */\n\tboolean isSupported();\n\n\t/**\n\t * Checks if the auto start feature is enabled for the application.\n\t *\n\t * @return true if auto start is enabled\n\t */\n\tboolean isEnabled();\n\n\t/**\n\t * Enables auto start for the application.\n\t */\n\tvoid enable();\n\n\t/**\n\t * Disables auto start for the application.\n\t */\n\tvoid disable();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/autostart/autostarter/AutoStarterGeneric.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.autostart.autostarter;\n\nimport io.xeres.app.application.autostart.AutoStarter;\n\npublic class AutoStarterGeneric implements AutoStarter\n{\n\t@Override\n\tpublic boolean isSupported()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic boolean isEnabled()\n\t{\n\t\tthrow new UnsupportedOperationException();\n\t}\n\n\t@Override\n\tpublic void enable()\n\t{\n\t\tthrow new UnsupportedOperationException();\n\t}\n\n\t@Override\n\tpublic void disable()\n\t{\n\t\tthrow new UnsupportedOperationException();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/autostart/autostarter/AutoStarterWindows.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.autostart.autostarter;\n\nimport io.xeres.app.application.autostart.AutoStarter;\nimport io.xeres.common.AppName;\nimport io.xeres.common.util.OsUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\nimport static com.sun.jna.platform.win32.Advapi32Util.*;\nimport static com.sun.jna.platform.win32.WinReg.HKEY_CURRENT_USER;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n/**\n * Handles the automatic startup of the application by Windows.\n * <p>\n * In case of problems, press ctrl-alt-del, launch the Task Manager, go to Startup apps and\n * make sure the status is set to Enabled.\n */\npublic class AutoStarterWindows implements AutoStarter\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(AutoStarterWindows.class);\n\n\tpublic static final String REGISTRY_RUN_PATH = \"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\";\n\tpublic static final String EXECUTABLE_EXTENSION = \".exe\";\n\n\tprivate Path applicationPath;\n\n\t@Override\n\tpublic boolean isSupported()\n\t{\n\t\treturn isNotBlank(getApplicationPath());\n\t}\n\n\t@Override\n\tpublic boolean isEnabled()\n\t{\n\t\treturn registryValueExists(HKEY_CURRENT_USER, REGISTRY_RUN_PATH, AppName.NAME);\n\t}\n\n\t@Override\n\tpublic void enable()\n\t{\n\t\tregistrySetStringValue(HKEY_CURRENT_USER, REGISTRY_RUN_PATH, AppName.NAME, \"\\\"\" + getApplicationPath() + \"\\\"\" + \" --iconified\");\n\t}\n\n\t@Override\n\tpublic void disable()\n\t{\n\t\tregistryDeleteValue(HKEY_CURRENT_USER, REGISTRY_RUN_PATH, AppName.NAME);\n\t}\n\n\t/**\n\t * Gets the application path.\n\t *\n\t * @return the application path\n\t */\n\tprivate String getApplicationPath()\n\t{\n\t\tif (applicationPath != null)\n\t\t{\n\t\t\treturn applicationPath.toString();\n\t\t}\n\n\t\tvar basePath = OsUtils.getApplicationHome();\n\n\t\t// Get the parent directory of 'app' because that's where the executable is\n\t\tif (basePath.getParent() == null)\n\t\t{\n\t\t\tlog.error(\"Couldn't get parent directory of application path {}\", basePath);\n\t\t\treturn null;\n\t\t}\n\n\t\tvar appPath = basePath.getParent().resolve(AppName.NAME + EXECUTABLE_EXTENSION);\n\t\tif (Files.notExists(appPath))\n\t\t{\n\t\t\tlog.error(\"Application path does not exist: {}\", appPath);\n\t\t\treturn null;\n\t\t}\n\n\t\tapplicationPath = appPath.toAbsolutePath();\n\n\t\tlog.info(\"Application path: {}\", appPath);\n\n\t\treturn applicationPath.toString();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/environment/Cloud.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.environment;\n\nimport io.xeres.common.properties.StartupProperties;\n\nimport java.util.Arrays;\n\nimport static io.xeres.common.properties.StartupProperties.Property.UI;\n\n/**\n * Utility class containing cloud related functions.\n */\npublic final class Cloud\n{\n\tprivate Cloud()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Checks if we are running on the cloud. This is done by checking if the profile <i>cloud</i> is in the <b>SPRING_PROFILES_ACTIVE</b> env variable.\n\t *\n\t * @return true if running on the cloud\n\t */\n\tprivate static boolean isRunningOnCloud()\n\t{\n\t\tvar profiles = System.getenv(\"SPRING_PROFILES_ACTIVE\");\n\t\tif (profiles != null)\n\t\t{\n\t\t\tvar tokens = profiles.split(\",\");\n\t\t\treturn Arrays.asList(tokens).contains(\n\t\t\t\t\t\"cloud\");\n\t\t}\n\t\treturn false;\n\t}\n\n\tpublic static void checkIfRunningOnCloud()\n\t{\n\t\tif (isRunningOnCloud())\n\t\t{\n\t\t\tStartupProperties.setBoolean(UI, \"false\", StartupProperties.Origin.ENVIRONMENT_VARIABLE);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/environment/CommandArgument.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.environment;\n\nimport io.xeres.common.AppName;\nimport io.xeres.common.mui.MUI;\nimport io.xeres.common.properties.StartupProperties;\nimport org.springframework.boot.ApplicationArguments;\nimport org.springframework.boot.DefaultApplicationArguments;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\n/**\n * Utility class to handle user supplied command line arguments.\n */\npublic final class CommandArgument\n{\n\tprivate CommandArgument()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static final String HELP = \"help\";\n\tprivate static final String VERSION = \"version\";\n\tprivate static final String NO_GUI = \"no-gui\";\n\tprivate static final String DATA_DIR = \"data-dir\";\n\tprivate static final String CONTROL_PORT = \"control-port\";\n\tprivate static final String CONTROL_ADDRESS = \"control-address\";\n\tprivate static final String NO_CONTROL_PASSWORD = \"no-control-password\";\n\tprivate static final String SERVER_ADDRESS = \"server-address\";\n\tprivate static final String SERVER_PORT = \"server-port\";\n\tprivate static final String FAST_SHUTDOWN = \"fast-shutdown\";\n\tprivate static final String REMOTE_PASSWORD = \"remote-password\";\n\tprivate static final String SERVER_ONLY = \"server-only\";\n\tprivate static final String REMOTE_CONNECT = \"remote-connect\";\n\tprivate static final String ICONIFIED = \"iconified\";\n\tprivate static final String NO_HTTPS = \"no-https\";\n\n\t/**\n\t * Parses command line arguments. Should be called before Spring Boot is initialized.\n\t *\n\t * @param args the command line arguments\n\t */\n\tpublic static void parse(String[] args)\n\t{\n\t\tvar appArgs = new DefaultApplicationArguments(args);\n\n\t\tfor (var arg : appArgs.getNonOptionArgs())\n\t\t{\n\t\t\tswitch (arg)\n\t\t\t{\n\t\t\t\tcase \"-h\", \"-help\", \"help\" -> showHelp();\n\t\t\t\tdefault -> throw new IllegalArgumentException(\"Unknown argument [\" + arg + \"]. Run with the --help argument.\");\n\t\t\t}\n\t\t}\n\n\t\tfor (var arg : appArgs.getOptionNames())\n\t\t{\n\t\t\tswitch (arg)\n\t\t\t{\n\t\t\t\tcase HELP -> showHelp();\n\t\t\t\tcase VERSION -> showVersion();\n\t\t\t\tcase DATA_DIR -> setString(StartupProperties.Property.DATA_DIR, appArgs, arg);\n\t\t\t\tcase CONTROL_PORT -> {\n\t\t\t\t\tsetPort(StartupProperties.Property.CONTROL_PORT, appArgs, arg);\n\t\t\t\t\tsetPort(StartupProperties.Property.UI_PORT, appArgs, arg);\n\t\t\t\t}\n\t\t\t\tcase CONTROL_ADDRESS -> setString(StartupProperties.Property.CONTROL_ADDRESS, appArgs, arg);\n\t\t\t\tcase NO_CONTROL_PASSWORD -> setBooleanInverted(StartupProperties.Property.CONTROL_PASSWORD, appArgs, arg);\n\t\t\t\tcase SERVER_ADDRESS -> setString(StartupProperties.Property.SERVER_ADDRESS, appArgs, arg);\n\t\t\t\tcase SERVER_PORT -> setPort(StartupProperties.Property.SERVER_PORT, appArgs, arg);\n\t\t\t\tcase REMOTE_CONNECT -> {\n\t\t\t\t\tvar ipAndPort = emptyIfNull(appArgs.getOptionValues(arg)).stream()\n\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t.orElseThrow(() -> new IllegalArgumentException(REMOTE_CONNECT + \" must specify a host or host:port like 'localhost' or 'localhost:6232'\"));\n\t\t\t\t\tStartupProperties.setUiRemoteConnect(ipAndPort, StartupProperties.Origin.ARGUMENT);\n\t\t\t\t}\n\t\t\t\tcase REMOTE_PASSWORD -> setString(StartupProperties.Property.REMOTE_PASSWORD, appArgs, arg);\n\t\t\t\tcase NO_GUI -> setBooleanInverted(StartupProperties.Property.UI, appArgs, arg);\n\t\t\t\tcase FAST_SHUTDOWN -> setBoolean(StartupProperties.Property.FAST_SHUTDOWN, appArgs, arg);\n\t\t\t\tcase SERVER_ONLY -> setBoolean(StartupProperties.Property.SERVER_ONLY, appArgs, arg);\n\t\t\t\tcase ICONIFIED -> setBoolean(StartupProperties.Property.ICONIFIED, appArgs, arg);\n\t\t\t\tcase NO_HTTPS -> setBooleanInverted(StartupProperties.Property.HTTPS, appArgs, arg);\n\t\t\t\tdefault -> throw new IllegalArgumentException(\"Unknown argument \" + arg);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static void setBoolean(StartupProperties.Property property, ApplicationArguments appArgs, String arg)\n\t{\n\t\tif (!emptyIfNull(appArgs.getOptionValues(arg)).isEmpty())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"--\" + arg + \" doesn't expect a value\");\n\t\t}\n\t\tStartupProperties.setBoolean(property, \"true\", StartupProperties.Origin.ARGUMENT);\n\t}\n\n\tprivate static void setBooleanInverted(StartupProperties.Property property, ApplicationArguments appArgs, String arg)\n\t{\n\t\tif (!emptyIfNull(appArgs.getOptionValues(arg)).isEmpty())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"--\" + arg + \" doesn't expect a value\");\n\t\t}\n\t\tStartupProperties.setBoolean(property, \"false\", StartupProperties.Origin.ARGUMENT);\n\t}\n\n\tprivate static void setString(StartupProperties.Property property, ApplicationArguments appArgs, String arg)\n\t{\n\t\ttry\n\t\t{\n\t\t\tStartupProperties.setString(property, getValue(appArgs, arg), StartupProperties.Origin.ARGUMENT);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"--\" + arg + \" does not contain a value\");\n\t\t}\n\t}\n\n\tprivate static void setPort(StartupProperties.Property property, ApplicationArguments appArgs, String arg)\n\t{\n\t\ttry\n\t\t{\n\t\t\tStartupProperties.setPort(property, getValue(appArgs, arg), StartupProperties.Origin.ARGUMENT);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"--\" + arg + \" must specify a port bigger than 0 and smaller than 65536\");\n\t\t}\n\t}\n\n\tprivate static String getValue(ApplicationArguments appArgs, String arg)\n\t{\n\t\tvar optionValues = emptyIfNull(appArgs.getOptionValues(arg));\n\t\tif (optionValues.isEmpty())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"--\" + arg + \" expects a value\");\n\t\t}\n\t\telse if (optionValues.size() > 1)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"--\" + arg + \" cannot be specified more than once\");\n\t\t}\n\t\treturn optionValues.getFirst();\n\t}\n\n\tprivate static void showHelp()\n\t{\n\t\tvar output = String.format(\"\"\"\n\t\t\t\tUsage: %s [--options]\n\t\t\t\twhere options include:\n\t\t\t\t   --no-gui                            start without an UI\n\t\t\t\t   --iconified                         start iconified into the tray\n\t\t\t\t   --data-dir=<path>                   specify the data directory\n\t\t\t\t   --control-address=<host>            specify the address to bind to for incoming remote access (defaults to 127.0.0.1)\n\t\t\t\t   --control-port=<port>               specify the control port for remote access\n\t\t\t\t   --no-control-password               do not protect the control address with a password\n\t\t\t\t   --no-https                          do not use HTTPS for the control connection\n\t\t\t\t   --server-address=<host>             specify a local address to bind to (defaults to all interfaces)\n\t\t\t\t   --server-port=<port>                specify the local port to bind to for incoming peer connections\n\t\t\t\t   --fast-shutdown                     ignore proper shutdown procedure (not recommended)\n\t\t\t\t   --server-only                       only accept incoming connections, do not make outgoing ones\n\t\t\t\t   --remote-connect=<host>[:<port>]    act as an UI client only and connect to a remote server\n\t\t\t\t   --remote-password=<password>        password to use when connecting remotely\n\t\t\t\t   --version                           print the version of the software\n\t\t\t\t   --help                              print this help message\n\t\t\t\tSee https://xeres.io/docs/ for more details.\n\t\t\t\t\"\"\", AppName.NAME);\n\n\t\tportableOutput(output);\n\t\tSystem.exit(0);\n\t}\n\n\tprivate static void showVersion()\n\t{\n\t\tvar buildInfo = CommandArgument.class.getClassLoader().getResourceAsStream(\"META-INF/build-info.properties\");\n\t\tif (buildInfo != null)\n\t\t{\n\t\t\ttry (var reader = new BufferedReader(new InputStreamReader(buildInfo)))\n\t\t\t{\n\t\t\t\treader.lines().filter(s -> s.startsWith(\"build.version=\"))\n\t\t\t\t\t\t.forEach(s -> portableOutput(AppName.NAME + \" \" + s.substring(s.indexOf('=') + 1)));\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tportableOutput(\"Couldn't get version information: \" + e.getMessage());\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tportableOutput(\"Couldn't get version information: resource not found\");\n\t\t}\n\t\tSystem.exit(0);\n\t}\n\n\tprivate static void portableOutput(String s)\n\t{\n\t\tif (System.console() != null)\n\t\t{\n\t\t\tSystem.out.print(s);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tMUI.showInformation(s);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/environment/DefaultProperties.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.environment;\n\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.properties.StartupProperties.Origin;\nimport io.xeres.common.util.OsUtils;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\n\nimport static io.xeres.common.properties.StartupProperties.Property.HTTPS;\nimport static io.xeres.common.properties.StartupProperties.Property.LOGFILE;\n\npublic final class DefaultProperties\n{\n\tprivate DefaultProperties()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void setDefaults()\n\t{\n\t\t// We default to HTTPS and have to specify it here because RemoteUtils\n\t\t// uses the property to know in which mode it is.\n\t\tStartupProperties.setBoolean(HTTPS, \"true\", Origin.PROPERTY);\n\n\t\t// If we're running from jpackage (aka, we're a final installation),\n\t\t// then we set the log file to a sensible path. We have to do it early too!\n\t\tif (OsUtils.isInstalled())\n\t\t{\n\t\t\tvar logFile = OsUtils.getLogFile();\n\t\t\ttry\n\t\t\t{\n\t\t\t\tFiles.createDirectories(logFile.getParent());\n\t\t\t\tStartupProperties.setString(LOGFILE, logFile.toString(), Origin.PROPERTY);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/environment/HostVariable.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.environment;\n\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.properties.StartupProperties.Property;\n\nimport java.util.Optional;\n\nimport static io.xeres.common.properties.StartupProperties.Property.*;\n\n/**\n * This utility class allows setting properties using the content of env variables.\n * This is especially useful when run from containers.\n */\npublic final class HostVariable\n{\n\t/**\n\t * The location of the data directory. Either an absolute or a relative path.\n\t */\n\tprivate static final String XERES_DATA_DIR = \"XERES_DATA_DIR\";\n\n\t/**\n\t * The control port of the server (that is, where the UI client is sending commands to).\n\t */\n\tprivate static final String XERES_CONTROL_PORT = \"XERES_CONTROL_PORT\";\n\n\t/**\n\t * The interface address to bind to (default: all).\n\t */\n\tprivate static final String XERES_SERVER_ADDRESS = \"XERES_SERVER_ADDRESS\";\n\n\t/**\n\t * The incoming port for peer connections.\n\t */\n\tprivate static final String XERES_SERVER_PORT = \"XERES_SERVER_PORT\";\n\n\t/**\n\t * If we are running in server mode only (that is, we're only accepting incoming connections).\n\t * Ideal for a chat server.\n\t */\n\tprivate static final String XERES_SERVER_ONLY = \"XERES_SERVER_ONLY\";\n\n\tprivate static final String XERES_HTTPS = \"XERES_HTTPS\";\n\n\tprivate static final String XERES_CONTROL_PASSWORD = \"XERES_CONTROL_PASSWORD\";\n\n\tprivate static final String ENVIRONMENT_VARIABLE_STRING = \"Environment variable\";\n\n\tprivate HostVariable()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Sets properties using env variables.\n\t */\n\tpublic static void parse()\n\t{\n\t\tget(XERES_DATA_DIR).ifPresent(s -> setString(XERES_DATA_DIR, DATA_DIR, s));\n\t\tget(XERES_SERVER_ONLY).ifPresent(s -> setBoolean(XERES_SERVER_ONLY, SERVER_ONLY, s));\n\t\tget(XERES_CONTROL_PORT).ifPresent(s -> {\n\t\t\tsetPort(XERES_CONTROL_PORT, CONTROL_PORT, s);\n\t\t\tsetPort(XERES_CONTROL_PORT, UI_PORT, s);\n\t\t});\n\t\tget(XERES_SERVER_ADDRESS).ifPresent(s -> setString(XERES_SERVER_ADDRESS, SERVER_ADDRESS, s));\n\t\tget(XERES_SERVER_PORT).ifPresent(s -> setPort(XERES_SERVER_PORT, SERVER_PORT, s));\n\t\tget(XERES_HTTPS).ifPresent(s -> setBoolean(XERES_HTTPS, HTTPS, s));\n\t\tget(XERES_CONTROL_PASSWORD).ifPresent(s -> setBoolean(XERES_CONTROL_PASSWORD, CONTROL_PASSWORD, s));\n\t}\n\n\tprivate static Optional<String> get(String key)\n\t{\n\t\treturn Optional.ofNullable(System.getenv(key));\n\t}\n\n\tprivate static void setString(String name, Property property, String value)\n\t{\n\t\ttry\n\t\t{\n\t\t\tStartupProperties.setString(property, value, StartupProperties.Origin.ENVIRONMENT_VARIABLE);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(ENVIRONMENT_VARIABLE_STRING + \" \" + name + \" does not contain a value\");\n\t\t}\n\t}\n\n\tprivate static void setBoolean(String name, Property property, String value)\n\t{\n\t\ttry\n\t\t{\n\t\t\tStartupProperties.setBoolean(property, value, StartupProperties.Origin.ENVIRONMENT_VARIABLE);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(ENVIRONMENT_VARIABLE_STRING + \" \" + name + \" does not contain a boolean value (\" + value + \")\");\n\t\t}\n\t}\n\n\tprivate static void setPort(String name, Property property, String value)\n\t{\n\t\ttry\n\t\t{\n\t\t\tStartupProperties.setPort(property, value, StartupProperties.Origin.ENVIRONMENT_VARIABLE);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(ENVIRONMENT_VARIABLE_STRING + \" \" + name + \" does not contain a valid port bigger than 0 and smaller than 65536 (\" + value + \")\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/environment/LocalPortFinder.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.environment;\n\nimport io.xeres.common.properties.StartupProperties;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.net.ServerSocket;\n\nimport static io.xeres.common.properties.StartupProperties.Property.*;\n\npublic final class LocalPortFinder\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(LocalPortFinder.class);\n\n\tprivate static final int DEFAULT_PORT = 6232;\n\tprivate static final int MAX_INSTANCES = 1024;\n\n\tprivate LocalPortFinder()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void ensureFreePort()\n\t{\n\t\tvar uiAddress = StartupProperties.getBoolean(UI_ADDRESS);\n\t\tif (uiAddress != null)\n\t\t{\n\t\t\treturn; // Don't bother with free port selection if we only want to connect to a remote client\n\t\t}\n\t\tvar port = StartupProperties.getInteger(CONTROL_PORT);\n\t\tif (port == null)\n\t\t{\n\t\t\tport = DEFAULT_PORT;\n\t\t}\n\t\tvar portMax = Math.min(65536, port + MAX_INSTANCES);\n\t\tvar portFound = -1;\n\n\t\tfor (int i = port; i < portMax; i++)\n\t\t{\n\t\t\ttry (var ignored = new ServerSocket(i))\n\t\t\t{\n\t\t\t\tportFound = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcatch (IOException _)\n\t\t\t{\n\t\t\t\t// Port already in use\n\t\t\t}\n\t\t}\n\n\t\tif (portFound == -1)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"No local port available, tried range: \" + port + \"-\" + portMax);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (port != portFound)\n\t\t\t{\n\t\t\t\tlog.info(\"Local port {} already used, using {} instead\", port, portFound);\n\t\t\t}\n\t\t\t// Make sure the properties are always set\n\t\t\tStartupProperties.setPort(CONTROL_PORT, String.valueOf(portFound), StartupProperties.Origin.PROPERTY);\n\t\t\tStartupProperties.setPort(UI_PORT, String.valueOf(portFound), StartupProperties.Origin.PROPERTY);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/DhtNodeFoundEvent.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.HostPort;\n\n/**\n * This event is sent when a node is found using the DHT.\n *\n * @param locationIdentifier the location identifier\n * @param hostPort   the host and port of the node\n */\npublic record DhtNodeFoundEvent(LocationIdentifier locationIdentifier, HostPort hostPort)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/IpChangedEvent.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\n/**\n * This event is sent when the current local IP changed.\n *\n * @param localIpAddress the new IP address\n */\npublic record IpChangedEvent(String localIpAddress)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/LocationReadyEvent.java",
    "content": "/*\n * Copyright (c) 2019-2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\n/**\n * Event that is sent once the application has a location (that is, a profile + location has been created or is available)\n * and is thus ready to start the network to connect to other peers.\n */\npublic record LocationReadyEvent()\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/NetworkReadyEvent.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\n/**\n * Event that is sent once the network is ready (aka the peer service is started).\n */\npublic record NetworkReadyEvent()\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/PeerConnectedEvent.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\nimport io.xeres.common.id.LocationIdentifier;\n\npublic record PeerConnectedEvent(LocationIdentifier locationIdentifier)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/PeerDisconnectedEvent.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\nimport io.xeres.common.id.LocationIdentifier;\n\n/**\n * Event that is sent when a peer is disconnected.\n *\n * @param id                 the location id\n * @param locationIdentifier the location identifier\n */\npublic record PeerDisconnectedEvent(long id, LocationIdentifier locationIdentifier)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/SettingsChangedEvent.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\nimport io.xeres.app.database.model.settings.Settings;\n\n/**\n * This event is sent when the settings are changed.\n *\n * @param oldSettings the old settings\n * @param newSettings the new settings\n */\npublic record SettingsChangedEvent(Settings oldSettings, Settings newSettings)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/UpnpEvent.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.events;\n\n/**\n * This event is sent when there's some update on the UPNP side.\n *\n * @param localPort       the local port\n * @param portsForwarded  if true, the ports have been forwarded with UPNP\n * @param externalIpFound if true, the external IP address has been found\n */\npublic record UpnpEvent(int localPort, boolean portsForwarded, boolean externalIpFound)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/application/events/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Spring application events.\n * <b>Beware:</b> those events are <b>asynchronous</b> which means they'll run in a new thread. If you\n * need a synchronous event, make sure your event implements SynchronousEvent.\n */\npackage io.xeres.app.application.events;\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/AsynchronousEventsConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.common.events.SynchronousEvent;\nimport org.springframework.context.ApplicationEvent;\nimport org.springframework.context.ApplicationListener;\nimport org.springframework.context.PayloadApplicationEvent;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.context.event.ApplicationEventMulticaster;\nimport org.springframework.context.event.SimpleApplicationEventMulticaster;\nimport org.springframework.core.ResolvableType;\nimport org.springframework.core.task.SimpleAsyncTaskExecutor;\n\nimport java.util.concurrent.RejectedExecutionException;\n\n/**\n * This configuration makes the events asynchronous, that is, the method\n * publishing them will return immediately instead of blocking. If you want synchronous events,\n * just make them implement SynchronousEvent.\n */\n@Configuration\npublic class AsynchronousEventsConfiguration\n{\n\t@Bean(name = \"applicationEventMulticaster\")\n\tpublic ApplicationEventMulticaster simpleApplicationEventMulticaster()\n\t{\n\t\tvar eventMulticaster = new SimpleApplicationEventMulticaster()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void multicastEvent(ApplicationEvent event, ResolvableType eventType)\n\t\t\t{\n\t\t\t\tvar type = eventType != null ? eventType : ResolvableType.forInstance(event);\n\t\t\t\tvar executor = getTaskExecutor();\n\n\t\t\t\tfor (ApplicationListener<?> listener : getApplicationListeners(event, type))\n\t\t\t\t{\n\t\t\t\t\tif (executor != null && listener.supportsAsyncExecution() && !isSynchronousEvent(event))\n\t\t\t\t\t{\n\t\t\t\t\t\ttry\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\texecutor.execute(() -> invokeListener(listener, event));\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcatch (RejectedExecutionException _)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tinvokeListener(listener, event);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tinvokeListener(listener, event);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t\teventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor());\n\t\treturn eventMulticaster;\n\t}\n\n\tprivate static boolean isSynchronousEvent(ApplicationEvent event)\n\t{\n\t\tif (event instanceof SynchronousEvent)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\t//noinspection RedundantIfStatement\n\t\tif (event instanceof PayloadApplicationEvent && ((PayloadApplicationEvent<?>) event).getPayload() instanceof SynchronousEvent)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/AutoStartConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.application.autostart.AutoStarter;\nimport io.xeres.app.application.autostart.autostarter.AutoStarterGeneric;\nimport io.xeres.app.application.autostart.autostarter.AutoStarterWindows;\nimport io.xeres.common.condition.OnWindowsCondition;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Conditional;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * Sets up the autostart feature that starts Xeres when the users logs in.\n * Currently implemented for Windows only.\n */\n@Configuration\npublic class AutoStartConfiguration\n{\n\t@Bean\n\t@Conditional(OnWindowsCondition.class)\n\tAutoStarter windowsAutoStarter()\n\t{\n\t\treturn new AutoStarterWindows();\n\t}\n\n\t@Bean\n\t@ConditionalOnMissingBean\n\tAutoStarter genericAutoStarter()\n\t{\n\t\treturn new AutoStarterGeneric();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/CacheDirConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.util.DevUtils;\nimport io.xeres.common.util.OsUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.core.env.Environment;\nimport org.springframework.core.env.Profiles;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\n/**\n * This configuration handles the cache directory location. This is stored locally and is deleted upon\n * uninstallation.\n * <p>\n * Portable versions use a cache directory alongside the data directory.\n */\n@Configuration\npublic class CacheDirConfiguration\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(CacheDirConfiguration.class);\n\n\tprivate final Environment environment;\n\n\tprivate String cacheDir;\n\n\tpublic CacheDirConfiguration(Environment environment)\n\t{\n\t\tthis.environment = environment;\n\t}\n\n\tpublic String getCacheDir()\n\t{\n\t\tif (cacheDir != null)\n\t\t{\n\t\t\treturn cacheDir;\n\t\t}\n\n\t\t// If a datasource is already set (that is, tests), then we don't return anything\n\t\tif (environment.getProperty(\"spring.datasource.url\") != null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tif (environment.acceptsProfiles(Profiles.of(\"dev\")))\n\t\t{\n\t\t\tcacheDir = DevUtils.getDirFromDevelopmentSetup(\"cache\");\n\t\t}\n\n\t\tif (cacheDir == null)\n\t\t{\n\t\t\tcacheDir = getCacheDirFromPortableFileLocation();\n\t\t}\n\n\t\tif (cacheDir == null)\n\t\t{\n\t\t\tcacheDir = OsUtils.getCacheDir().toString();\n\t\t}\n\n\t\tvar path = Path.of(cacheDir);\n\t\tif (Files.notExists(path))\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tFiles.createDirectory(path);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Couldn't create cache directory: {}, {}. Cache won't be available\", cacheDir, e.getMessage());\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}\n\t\treturn cacheDir;\n\t}\n\n\tprivate static String getCacheDirFromPortableFileLocation()\n\t{\n\t\tvar portable = Path.of(\"portable\");\n\t\tif (Files.exists(portable))\n\t\t{\n\t\t\treturn portable.resolveSibling(\"Cache\").toAbsolutePath().toString();\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/CustomCsrfChannelInterceptor.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport org.springframework.messaging.support.ChannelInterceptor;\nimport org.springframework.stereotype.Component;\n\n/**\n * The following disables WebSocket's CSRF.\n */\n@Component(\"csrfChannelInterceptor\")\npublic class CustomCsrfChannelInterceptor implements ChannelInterceptor\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/DataDirConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.application.SingleInstanceRun;\nimport io.xeres.app.util.DevUtils;\nimport io.xeres.common.AppName;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.util.OsUtils;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.core.env.Environment;\nimport org.springframework.core.env.Profiles;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Objects;\n\nimport static io.xeres.common.properties.StartupProperties.Property.DATA_DIR;\n\n/**\n * Configuration for everything related to the user data directory (database, keys, user data, ...).\n */\n@Configuration\npublic class DataDirConfiguration\n{\n\tprivate static final String LOCAL_DATA = \"data\";\n\n\tprivate final Environment environment;\n\n\tprivate String dataDir;\n\n\tpublic DataDirConfiguration(Environment environment)\n\t{\n\t\tthis.environment = environment;\n\t}\n\n\t/**\n\t * Gets the data directory where all user data is stored.\n\t * Note: this is not really used as a proper bean. DataSourceConfiguration depends on it, but it's accessed by the method.\n\t * @return the path to the data directory\n\t */\n\t@Bean\n\tpublic String getDataDir()\n\t{\n\t\tif (dataDir != null)\n\t\t{\n\t\t\treturn dataDir;\n\t\t}\n\n\t\t// If a datasource is already set (that is, tests), then we don't return anything\n\t\tif (environment.getProperty(\"spring.datasource.url\") != null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tdataDir = getDataDirFromArgs();\n\t\tif (dataDir == null && environment.acceptsProfiles(Profiles.of(\"dev\")))\n\t\t{\n\t\t\tdataDir = DevUtils.getDirFromDevelopmentSetup(LOCAL_DATA);\n\t\t}\n\n\t\tif (dataDir == null)\n\t\t{\n\t\t\tdataDir = getDataDirFromPortableFileLocation();\n\t\t}\n\t\tif (dataDir == null)\n\t\t{\n\t\t\tdataDir = OsUtils.getDataDir().toString();\n\t\t}\n\n\t\tObjects.requireNonNull(dataDir);\n\n\t\tvar path = Path.of(dataDir);\n\n\t\tif (Files.notExists(path))\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tFiles.createDirectory(path);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Couldn't create data directory: \" + dataDir + \", :\" + e.getMessage());\n\t\t\t}\n\t\t}\n\n\t\tif (!SingleInstanceRun.enforceSingleInstance(dataDir))\n\t\t{\n\t\t\tthrow new IllegalStateException(\"An instance of \" + AppName.NAME + \" is already running, path: \" + dataDir);\n\t\t}\n\n\t\treturn dataDir;\n\t}\n\n\tprivate static String getDataDirFromArgs()\n\t{\n\t\treturn StartupProperties.getString(DATA_DIR);\n\t}\n\n\tprivate static String getDataDirFromPortableFileLocation()\n\t{\n\t\tvar portable = Path.of(\"portable\");\n\t\tif (Files.exists(portable))\n\t\t{\n\t\t\treturn portable.resolveSibling(LOCAL_DATA).toAbsolutePath().toString();\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/DataSourceConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.properties.DatabaseProperties;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.service.UiBridgeService.SplashStatus;\nimport org.h2.tools.Upgrade;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.boot.jdbc.DataSourceBuilder;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.context.annotation.DependsOn;\n\nimport javax.sql.DataSource;\nimport java.io.BufferedReader;\nimport java.io.FileReader;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Properties;\n\n/**\n * Configuration for the location and options of the database.\n */\n@Configuration\n@DependsOn(\"getDataDir\")\npublic class DataSourceConfiguration\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DataSourceConfiguration.class);\n\n\tprivate static final int H2_UPGRADE_FROM_VERSION = 214;\n\tprivate static final int H2_UPGRADE_CURRENT_FORMAT = 3;\n\tprivate static final String H2_URL_PREFIX = \"jdbc:h2:file:\";\n\tprivate static final String H2_USERNAME = \"sa\";\n\n\tprivate final DatabaseProperties databaseProperties;\n\tprivate final DataDirConfiguration dataDirConfiguration;\n\tprivate final UiBridgeService uiBridgeService;\n\n\tpublic DataSourceConfiguration(DatabaseProperties databaseProperties, DataDirConfiguration dataDirConfiguration, UiBridgeService uiBridgeService)\n\t{\n\t\tthis.databaseProperties = databaseProperties;\n\t\tthis.dataDirConfiguration = dataDirConfiguration;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t}\n\n\t@Bean\n\t@ConditionalOnProperty(prefix = \"spring.datasource\", name = \"url\", havingValue = \"false\", matchIfMissing = true)\n\tpublic DataSource getDataSource()\n\t{\n\t\tuiBridgeService.setSplashStatus(SplashStatus.DATABASE);\n\n\t\tvar useJMX = \";JMX=TRUE\";\n\t\tvar disableTraces = \";TRACE_LEVEL_FILE=0\"; // Set to 4 for verbose output using Slf4J\n\n\t\tvar dataDir = Path.of(dataDirConfiguration.getDataDir(), \"userdata\").toString();\n\n\t\tlog.debug(\"Using database file: {}\", dataDir);\n\n\t\tvar dbOpts = \";DB_CLOSE_ON_EXIT=FALSE\";\n\n\t\tif (databaseProperties.getCacheSize() != null)\n\t\t{\n\t\t\tdbOpts += \";CACHE_SIZE=\" + databaseProperties.getCacheSize();\n\t\t}\n\n\t\tif (databaseProperties.getMaxCompactTime() != null)\n\t\t{\n\t\t\tdbOpts += \";MAX_COMPACT_TIME=\" + databaseProperties.getMaxCompactTime();\n\t\t}\n\n\t\tvar url = H2_URL_PREFIX + dataDir + dbOpts + useJMX + disableTraces;\n\n\t\tupgradeIfNeeded(url);\n\n\t\treturn DataSourceBuilder\n\t\t\t\t.create()\n\t\t\t\t.url(url)\n\t\t\t\t.username(H2_USERNAME)\n\t\t\t\t.driverClassName(\"org.h2.Driver\")\n\t\t\t\t.build();\n\t}\n\n\tprivate static void upgradeIfNeeded(String url)\n\t{\n\t\tif (!url.startsWith(H2_URL_PREFIX))\n\t\t{\n\t\t\tlog.debug(\"Not an H2 file, no upgrade needed\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar fileName = url.substring(13, url.indexOf(\";\")) + \".mv.db\";\n\t\tvar filePath = Path.of(fileName);\n\n\t\tif (!Files.exists(filePath) || !Files.isRegularFile(filePath))\n\t\t{\n\t\t\tlog.debug(\"No file present, no upgrade needed\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry (var reader = new BufferedReader(new FileReader(filePath.toFile())))\n\t\t{\n\t\t\tvar header = reader.readLine();\n\t\t\tif (header.contains(\"format:\" + H2_UPGRADE_CURRENT_FORMAT))\n\t\t\t{\n\t\t\t\tlog.debug(\"No upgrade needed for H2\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(\"Couldn't read database: \" + e.getMessage());\n\t\t}\n\n\t\tvar properties = new Properties();\n\t\tproperties.put(\"USER\", H2_USERNAME);\n\t\tproperties.put(\"PASSWORD\", \"\");\n\t\ttry\n\t\t{\n\t\t\tUpgrade.upgrade(url, properties, H2_UPGRADE_FROM_VERSION);\n\t\t}\n\t\tcatch (Exception e)\n\t\t{\n\t\t\tlog.error(\"Couldn't perform upgrade: {}\", e.getMessage(), e);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/EnumMappingConfiguration.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport org.springframework.boot.convert.ApplicationConversionService;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.format.FormatterRegistry;\nimport org.springframework.web.servlet.config.annotation.WebMvcConfigurer;\n\n/**\n * This configuration makes sure that enums in web parameters don't require\n * to be in uppercase.\n */\n@Configuration\npublic class EnumMappingConfiguration implements WebMvcConfigurer\n{\n\t@Override\n\tpublic void addFormatters(FormatterRegistry registry)\n\t{\n\t\tApplicationConversionService.configure(registry);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/GeoIpConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport com.maxmind.geoip2.DatabaseReader;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\nimport java.io.IOException;\nimport java.util.Objects;\n\n/**\n * This configuration sets up the GeoIP database.\n */\n@Configuration\npublic class GeoIpConfiguration\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GeoIpConfiguration.class);\n\n\t@Bean\n\tpublic DatabaseReader getDatabaseReader()\n\t{\n\t\tvar database = Objects.requireNonNull(GeoIpConfiguration.class.getResourceAsStream(\"/GeoLite2-Country.mmdb\"));\n\t\ttry\n\t\t{\n\t\t\treturn new DatabaseReader.Builder(database).build();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't setup GeoIP: {}\", e.getMessage(), e);\n\t\t\treturn null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/IdleTimeConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.xrs.service.status.GetIdleTime;\nimport io.xeres.app.xrs.service.status.idletimer.GetIdleTimeGeneric;\nimport io.xeres.app.xrs.service.status.idletimer.GetIdleTimeLinux;\nimport io.xeres.app.xrs.service.status.idletimer.GetIdleTimeMac;\nimport io.xeres.app.xrs.service.status.idletimer.GetIdleTimeWindows;\nimport io.xeres.common.condition.OnLinuxCondition;\nimport io.xeres.common.condition.OnMacCondition;\nimport io.xeres.common.condition.OnWindowsCondition;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Conditional;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * This configuration sets up the idle time detector to know\n * when the user is idle.\n */\n@Configuration\npublic class IdleTimeConfiguration\n{\n\t@Bean\n\t@Conditional(OnWindowsCondition.class)\n\tGetIdleTime windowsIdleTime()\n\t{\n\t\treturn new GetIdleTimeWindows();\n\t}\n\n\t@Bean\n\t@Conditional(OnLinuxCondition.class)\n\tGetIdleTime linuxIdleTime()\n\t{\n\t\treturn new GetIdleTimeLinux();\n\t}\n\n\t@Bean\n\t@Conditional(OnMacCondition.class)\n\tGetIdleTime macIdleTime()\n\t{\n\t\treturn new GetIdleTimeMac();\n\t}\n\n\t@Bean\n\t@ConditionalOnMissingBean\n\tGetIdleTime genericIdleTime()\n\t{\n\t\treturn new GetIdleTimeGeneric();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/SchedulerConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.scheduling.annotation.EnableScheduling;\n\n/**\n * Configuration of the scheduler. Just enables it. We use JDK 21 and virtual threads\n * are enabled so there's no need to set up a thread pool anymore.\n */\n@Configuration\n@EnableScheduling\npublic class SchedulerConfiguration\n{\n\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/SelfCertificateConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.crypto.rsa.RSA;\nimport org.apache.catalina.connector.Connector;\nimport org.bouncycastle.asn1.x500.X500Name;\nimport org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;\nimport org.bouncycastle.cert.X509v3CertificateBuilder;\nimport org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;\nimport org.bouncycastle.operator.OperatorCreationException;\nimport org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;\nimport org.springframework.boot.tomcat.TomcatConnectorCustomizer;\nimport org.springframework.boot.web.server.autoconfigure.ServerProperties;\nimport org.springframework.context.annotation.Configuration;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.math.BigInteger;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.security.KeyPair;\nimport java.security.KeyStore;\nimport java.security.KeyStoreException;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.cert.Certificate;\nimport java.security.cert.CertificateException;\nimport java.time.Instant;\nimport java.util.Date;\nimport java.util.Objects;\n\n/**\n * Strongly inspired from <a href=\"https://valb3r.github.io/letsencrypt-helper/\">let's encrypt helper</a> by Valentyn Berezin.\n */\n@Configuration\n@ConditionalOnExpression(\"'${server.ssl.enabled}' == 'true' && '${spring.main.web-application-type}' != 'none'\")\npublic class SelfCertificateConfiguration implements TomcatConnectorCustomizer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SelfCertificateConfiguration.class);\n\n\tprivate static final int KEY_SIZE = 3072;\n\n\tprivate final ServerProperties serverProperties;\n\n\tpublic SelfCertificateConfiguration(ServerProperties serverProperties, DataDirConfiguration dataDirConfiguration)\n\t{\n\t\tthis.serverProperties = serverProperties;\n\t\tif (dataDirConfiguration.getDataDir() == null) // Ignore for tests...\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tObjects.requireNonNull(this.serverProperties.getSsl(), \"Missing 'server.ssl.enabled' property\");\n\n\t\tthis.serverProperties.getSsl().setKeyStore(\"file:\" + Path.of(dataDirConfiguration.getDataDir(), \"keystore.pfx\").toAbsolutePath());\n\n\t\tcreateKeystoreIfNeeded();\n\t}\n\n\tprivate void createKeystoreIfNeeded()\n\t{\n\t\tvar keystoreFile = getKeystoreFile();\n\t\tif (keystoreFile.exists())\n\t\t{\n\t\t\tlog.debug(\"Keystore exists: {}\", keystoreFile.getAbsolutePath());\n\t\t\treturn;\n\t\t}\n\n\t\tlog.info(\"Creating self-signed certificate for HTTPS access...\");\n\t\tvar keystore = createKeystoreWithSelfSignedCertificate();\n\t\tsaveKeystore(keystoreFile, keystore);\n\t\tlog.info(\"Created keystore {}\", keystoreFile.getAbsolutePath());\n\t}\n\n\tprivate File getKeystoreFile()\n\t{\n\t\t//noinspection DataFlowIssue\n\t\treturn new File(parseCertificateKeystoreFilePath(serverProperties.getSsl().getKeyStore()));\n\t}\n\n\tprivate String parseCertificateKeystoreFilePath(String path)\n\t{\n\t\treturn path.replace(\"file://\", \"\").replace(\"file:\", \"\");\n\t}\n\n\tprivate KeyStore createKeystoreWithSelfSignedCertificate()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar domainKey = RSA.generateKeys(KEY_SIZE);\n\t\t\t//noinspection DataFlowIssue\n\t\t\tvar newKeystore = KeyStore.getInstance(serverProperties.getSsl().getKeyStoreType());\n\t\t\tnewKeystore.load(null, null);\n\t\t\tvar signedDomain = selfSign(domainKey, Instant.EPOCH, Instant.EPOCH);\n\t\t\tnewKeystore.setKeyEntry(serverProperties.getSsl().getKeyAlias(), domainKey.getPrivate(), keyPassword().toCharArray(), new Certificate[]{signedDomain});\n\t\t\treturn newKeystore;\n\t\t}\n\t\tcatch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tprivate Certificate selfSign(KeyPair keyPair, @SuppressWarnings(\"SameParameterValue\") Instant notBefore, @SuppressWarnings(\"SameParameterValue\") Instant notAfter)\n\t{\n\t\tvar dnName = new X500Name(\"CN=Xeres\");\n\t\tvar serialNumber = BigInteger.valueOf(Instant.now().toEpochMilli());\n\t\tvar subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded());\n\t\tvar builder = new X509v3CertificateBuilder(\n\t\t\t\tdnName,\n\t\t\t\tserialNumber,\n\t\t\t\tDate.from(notBefore),\n\t\t\t\tDate.from(notAfter),\n\t\t\t\tdnName,\n\t\t\t\tsubjectPublicKeyInfo\n\t\t);\n\t\ttry\n\t\t{\n\t\t\tvar contentSigner = new JcaContentSignerBuilder(\"SHA256WithRSA\").build(keyPair.getPrivate());\n\t\t\tvar certificateHolder = builder.build(contentSigner);\n\t\t\treturn new JcaX509CertificateConverter().getCertificate(certificateHolder);\n\t\t}\n\t\tcatch (CertificateException | OperatorCreationException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tprivate String keyPassword()\n\t{\n\t\t//noinspection DataFlowIssue\n\t\treturn serverProperties.getSsl().getKeyPassword() != null ? serverProperties.getSsl().getKeyPassword() : serverProperties.getSsl().getKeyStorePassword();\n\t}\n\n\tprivate void saveKeystore(File keystoreFile, KeyStore keystore)\n\t{\n\t\ttry (var out = Files.newOutputStream(keystoreFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING))\n\t\t{\n\t\t\t//noinspection DataFlowIssue\n\t\t\tkeystore.store(out, serverProperties.getSsl().getKeyStorePassword().toCharArray());\n\t\t}\n\t\tcatch (CertificateException | IOException | NoSuchAlgorithmException | KeyStoreException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void customize(Connector connector)\n\t{\n\t\t// This is needed so that our configuration is called early, before Tomcat is initialized.\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebConfiguration.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.ui.client.PaginatedResponse;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.data.web.config.EnableSpringDataWebSupport;\n\nimport static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;\n\n/**\n * This configuration is used to enable Paginated elements to be output as a stable JSON.\n * See the {@link PaginatedResponse} DTO.\n */\n@Configuration\n@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)\npublic class WebConfiguration\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebSecurityConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.common.properties.StartupProperties;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;\nimport org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;\nimport org.springframework.security.core.userdetails.User;\nimport org.springframework.security.core.userdetails.UserDetailsService;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;\n\nimport static io.xeres.common.properties.StartupProperties.Property.CONTROL_PASSWORD;\nimport static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;\n\n@Configuration\n@EnableWebSecurity\npublic class WebSecurityConfiguration\n{\n\t@Bean\n\tpublic SecurityFilterChain securityFilterChain(HttpSecurity http, SettingsService settingsService)\n\t{\n\t\thttp\n\t\t\t\t.csrf(AbstractHttpConfigurer::disable) // Not needed for desktop app\n\t\t\t\t.authorizeHttpRequests(authorize -> {\n\t\t\t\t\tauthorize.requestMatchers(\"/swagger-ui/**\", \"/v3/api-docs/**\").permitAll();\n\t\t\t\t\tif (settingsService.isRemoteEnabled())\n\t\t\t\t\t{\n\t\t\t\t\t\tif (settingsService.hasRemotePassword() && StartupProperties.getBoolean(CONTROL_PASSWORD, true))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tauthorize.anyRequest().authenticated();\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tauthorize.anyRequest().anonymous();\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tif (settingsService.hasRemotePassword() && StartupProperties.getBoolean(CONTROL_PASSWORD, true))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tauthorize.anyRequest().access(new WebExpressionAuthorizationManager(\"isAuthenticated() && hasIpAddress('127.0.0.1')\"));\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tauthorize.anyRequest().access(new WebExpressionAuthorizationManager(\"isAnonymous() && hasIpAddress('127.0.0.1')\"));\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t)\n\t\t\t\t.httpBasic(Customizer.withDefaults())\n\t\t\t\t.sessionManagement(session -> session.sessionCreationPolicy(STATELESS));\n\t\treturn http.build();\n\t}\n\n\t@Bean\n\tpublic UserDetailsService userDetailsService(SettingsService settingsService)\n\t{\n\t\tvar userDetails = User.withUsername(\"user\")\n\t\t\t\t.password(\"{noop}\" + settingsService.getRemotePassword())\n\t\t\t\t.roles(\"USER\")\n\t\t\t\t.build();\n\n\t\treturn new InMemoryUserDetailsManager(userDetails);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebServerConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.application.environment.LocalPortFinder;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.common.properties.StartupProperties;\nimport org.springframework.boot.web.server.WebServerFactoryCustomizer;\nimport org.springframework.boot.web.server.servlet.ConfigurableServletWebServerFactory;\nimport org.springframework.context.annotation.Configuration;\n\nimport java.net.InetAddress;\nimport java.net.UnknownHostException;\nimport java.util.Objects;\n\nimport static io.xeres.common.properties.StartupProperties.Property.CONTROL_PORT;\n\n@Configuration\npublic class WebServerConfiguration implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory>\n{\n\tprivate final SettingsService settingsService;\n\n\tpublic WebServerConfiguration(SettingsService settingsService)\n\t{\n\t\tthis.settingsService = settingsService;\n\t}\n\n\t@Override\n\tpublic void customize(ConfigurableServletWebServerFactory factory)\n\t{\n\t\t// If we are allowing remote access, bind to all interfaces\n\t\tif (StartupProperties.Property.CONTROL_ADDRESS.isUnset() && settingsService.isRemoteEnabled())\n\t\t{\n\t\t\tfactory.setAddress(getAllInterfaces());\n\t\t}\n\n\t\t// If the port configured in the settings is different from CONTROL_PORT, then use it instead.\n\t\tif (StartupProperties.Property.CONTROL_PORT.isUnset() && settingsService.hasRemotePortConfigured() && settingsService.getRemotePort() != Objects.requireNonNull(StartupProperties.getInteger(StartupProperties.Property.CONTROL_PORT)))\n\t\t{\n\t\t\tStartupProperties.setPort(CONTROL_PORT, String.valueOf(settingsService.getRemotePort()), StartupProperties.Origin.PROPERTY);\n\t\t\tLocalPortFinder.ensureFreePort();\n\t\t\tfactory.setPort(Objects.requireNonNull(StartupProperties.getInteger(StartupProperties.Property.CONTROL_PORT)));\n\t\t}\n\t}\n\n\tprivate static InetAddress getAllInterfaces()\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn InetAddress.getByAddress(new byte[]{0, 0, 0, 0});\n\t\t}\n\t\tcatch (UnknownHostException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebSocketConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.socket.config.annotation.EnableWebSocket;\nimport org.springframework.web.socket.config.annotation.WebSocketConfigurer;\nimport org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;\nimport org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;\n\nimport static io.xeres.common.message.MessagingConfiguration.MAXIMUM_MESSAGE_SIZE;\n\n@Configuration\n@EnableWebSocket\n@ConditionalOnExpression(\"'${spring.main.web-application-type}' != 'none'\")\npublic class WebSocketConfiguration implements WebSocketConfigurer\n{\n\t@Bean\n\tpublic ServletServerContainerFactoryBean createServletServerContainerFactoryBean()\n\t{\n\t\tvar container = new ServletServerContainerFactoryBean();\n\t\tcontainer.setMaxTextMessageBufferSize(MAXIMUM_MESSAGE_SIZE);\n\t\tcontainer.setMaxBinaryMessageBufferSize(MAXIMUM_MESSAGE_SIZE);\n\t\treturn container;\n\t}\n\n\t@Override\n\tpublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry)\n\t{\n\t\t// No custom handlers, we use STOMP\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebSocketLoggingConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport jakarta.annotation.PostConstruct;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.socket.config.WebSocketMessageBrokerStats;\n\n@Configuration\npublic class WebSocketLoggingConfiguration\n{\n\tprivate final WebSocketMessageBrokerStats webSocketMessageBrokerStats;\n\n\tpublic WebSocketLoggingConfiguration(WebSocketMessageBrokerStats webSocketMessageBrokerStats)\n\t{\n\t\tthis.webSocketMessageBrokerStats = webSocketMessageBrokerStats;\n\t}\n\n\t@PostConstruct\n\tprivate void init()\n\t{\n\t\t// Avoids stats messages printed each 30 minutes\n\t\twebSocketMessageBrokerStats.setLoggingPeriod(0L);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebSocketMessageBrokerConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.messaging.simp.config.MessageBrokerRegistry;\nimport org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;\nimport org.springframework.web.socket.config.annotation.StompEndpointRegistry;\nimport org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;\nimport org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;\nimport org.springframework.web.socket.messaging.SessionDisconnectEvent;\nimport org.springframework.web.socket.messaging.SessionSubscribeEvent;\nimport org.springframework.web.socket.messaging.SessionUnsubscribeEvent;\n\nimport static io.xeres.common.message.MessagePath.APP_PREFIX;\nimport static io.xeres.common.message.MessagePath.BROKER_PREFIX;\nimport static io.xeres.common.message.MessagingConfiguration.MAXIMUM_MESSAGE_SIZE;\n\n/**\n * Configuration of the WebSocket. This is used for anything that requires a persistent connection from\n * the UI client to the server because of a bidirectional data stream (for example, chat windows).\n */\n@Configuration\n@EnableWebSocketMessageBroker\npublic class WebSocketMessageBrokerConfiguration implements WebSocketMessageBrokerConfigurer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(WebSocketMessageBrokerConfiguration.class);\n\n\t@Override\n\tpublic void registerStompEndpoints(StompEndpointRegistry registry)\n\t{\n\t\tregistry.addEndpoint(\"/ws\");\n\t\tregistry.addEndpoint(\"/ws\").withSockJS();\n\t}\n\n\t@Override\n\tpublic void configureMessageBroker(MessageBrokerRegistry registry)\n\t{\n\t\tregistry.enableSimpleBroker(BROKER_PREFIX); // this is for the broker (subscriptions, ...)\n\t\tregistry.setApplicationDestinationPrefixes(APP_PREFIX); // this is for @Controller annotated endpoints using @MessageMapping and such\n\t}\n\n\t@EventListener\n\tpublic void handleSessionSubscribeEvent(SessionSubscribeEvent event)\n\t{\n\t\tlog.debug(\"Subscription from {}\", event);\n\t}\n\n\t@EventListener\n\tpublic void handleSessionUnsubscribeEvent(SessionUnsubscribeEvent event)\n\t{\n\t\tlog.debug(\"Unsubscription from {}\", event);\n\t}\n\n\t@EventListener\n\tpublic void handleSessionDisconnectEvent(SessionDisconnectEvent event)\n\t{\n\t\tlog.debug(\"Disconnection from {}\", event);\n\t}\n\n\t@Override\n\tpublic void configureWebSocketTransport(WebSocketTransportRegistration registry)\n\t{\n\t\tregistry.setMessageSizeLimit(MAXIMUM_MESSAGE_SIZE);\n\t\tregistry.setSendBufferSizeLimit(MAXIMUM_MESSAGE_SIZE);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/configuration/WebSocketSecurityConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.common.properties.StartupProperties;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.messaging.Message;\nimport org.springframework.security.authorization.AuthorityAuthorizationManager;\nimport org.springframework.security.authorization.AuthorizationManager;\nimport org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity;\nimport org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;\n\nimport static io.xeres.common.properties.StartupProperties.Property.CONTROL_PASSWORD;\n\n@Configuration\n@EnableWebSocketSecurity\npublic class WebSocketSecurityConfiguration\n{\n\t@Bean\n\tAuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages, SettingsService settingsService)\n\t{\n\t\tif (settingsService.hasRemotePassword() && StartupProperties.getBoolean(CONTROL_PASSWORD, true))\n\t\t{\n\t\t\treturn AuthorityAuthorizationManager.hasRole(\"USER\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn AuthorityAuthorizationManager.hasRole(\"ANONYMOUS\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/aead/AEAD.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.aead;\n\nimport io.xeres.app.crypto.hmac.sha256.Sha256HMac;\n\nimport javax.crypto.*;\nimport javax.crypto.spec.ChaCha20ParameterSpec;\nimport javax.crypto.spec.IvParameterSpec;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.nio.ByteBuffer;\nimport java.security.InvalidAlgorithmParameterException;\nimport java.security.InvalidKeyException;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Objects;\n\nimport static javax.crypto.Cipher.DECRYPT_MODE;\nimport static javax.crypto.Cipher.ENCRYPT_MODE;\n\n/**\n * Authenticated Encryption with Associated Data.\n * This implementation uses Encrypt-then-MAC (EtM).\n */\npublic final class AEAD\n{\n\tprivate static final String ENCRYPTION_TRANSFORMATION_CHACHA20_POLY1305 = \"ChaCha20-Poly1305\";\n\tprivate static final String ENCRYPTION_TRANSFORMATION_CHACHA20 = \"ChaCha20\";\n\tprivate static final String ENCRYPTION_ALGORITHM_CHACHA20 = \"ChaCha20\";\n\tprivate static final int TAG_SIZE = 16;\n\n\tprivate AEAD()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Generates a secret key.\n\t *\n\t * @return the secret key\n\t */\n\tpublic static SecretKey generateKey()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar keyGenerator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM_CHACHA20);\n\t\t\tkeyGenerator.init(256);\n\t\t\treturn keyGenerator.generateKey();\n\t\t}\n\t\tcatch (NoSuchAlgorithmException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\t/**\n\t * Encrypts using ChaCha20 as an AEAD cipher with Poly1305 as the authenticator.\n\t * @see <a href=\"https://datatracker.ietf.org/doc/html/rfc7539\">RFC 7539</a>\n\t * @param key                         the secret key, not null\n\t * @param nonce                       a unique, securely generated nonce, not null\n\t * @param plainText                   the data to encrypt, not null\n\t * @param additionalAuthenticatedData additional authenticated data. Is used to authenticate the nonce, not null\n\t * @return the encrypted data\n\t */\n\tpublic static byte[] encryptChaCha20Poly1305(SecretKey key, byte[] nonce, byte[] plainText, byte[] additionalAuthenticatedData)\n\t{\n\t\tObjects.requireNonNull(key);\n\t\tObjects.requireNonNull(nonce);\n\t\tObjects.requireNonNull(plainText);\n\t\tObjects.requireNonNull(additionalAuthenticatedData);\n\t\tif (nonce.length != 12)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Nonce must be 12 bytes\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\treturn doChaCha20Poly1305(key, ENCRYPT_MODE, nonce, plainText, additionalAuthenticatedData);\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\t/**\n\t * Decrypts using ChaCha20 as an AEAD cipher with Poly1305 as the authenticator.\n\t * @see <a href=\"https://datatracker.ietf.org/doc/html/rfc7539\">RFC 7539</a>\n\t * @param key                         the secret key, not null\n\t * @param nonce                       the unique, securely generated nonce that was used for the encryption, not null\n\t * @param cipherText                  the encrypted data, not null\n\t * @param additionalAuthenticatedData additional authenticated data. Is used to authenticate the nonce, not null\n\t * @return the decrypted data\n\t */\n\tpublic static byte[] decryptChaCha20Poly1305(SecretKey key, byte[] nonce, byte[] cipherText, byte[] additionalAuthenticatedData)\n\t{\n\t\tObjects.requireNonNull(key);\n\t\tObjects.requireNonNull(nonce);\n\t\tObjects.requireNonNull(cipherText);\n\t\tObjects.requireNonNull(additionalAuthenticatedData);\n\t\tif (nonce.length != 12)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Nonce must be 12 bytes\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\treturn doChaCha20Poly1305(key, DECRYPT_MODE, nonce, cipherText, additionalAuthenticatedData);\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\tprivate static byte[] doChaCha20Poly1305(SecretKey key, int operation, byte[] nonce, byte[] dataIn, byte[] additionalAuthenticatedData) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException\n\t{\n\t\tvar cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION_CHACHA20_POLY1305);\n\t\tvar ivParameterSpec = new IvParameterSpec(nonce);\n\t\tvar keySpec = new SecretKeySpec(key.getEncoded(), ENCRYPTION_ALGORITHM_CHACHA20);\n\t\tcipher.init(operation, keySpec, ivParameterSpec);\n\t\tcipher.updateAAD(additionalAuthenticatedData);\n\t\treturn cipher.doFinal(dataIn);\n\t}\n\n\t/**\n\t * Encrypts using ChaCha20 as an AEAD cipher with HMAC SHA-256.\n\t *\n\t * @param key                         the secret key, not null\n\t * @param nonce                       a unique, securely generated nonce, not null\n\t * @param plainText                   the data to encrypt, not null\n\t * @param additionalAuthenticatedData additional authenticated data. Can be used to authenticate the nonce, not null\n\t * @return the encrypted data\n\t * @see <a href=\"https://datatracker.ietf.org/doc/html/rfc7539\">RFC 7539</a>\n\t */\n\tpublic static byte[] encryptChaCha20Sha256(SecretKey key, byte[] nonce, byte[] plainText, byte[] additionalAuthenticatedData)\n\t{\n\t\tObjects.requireNonNull(key);\n\t\tObjects.requireNonNull(nonce);\n\t\tObjects.requireNonNull(plainText);\n\t\tObjects.requireNonNull(additionalAuthenticatedData);\n\t\tif (nonce.length != 12)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Nonce must be 12 bytes\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tvar encryptedData = doChaCha20(key, ENCRYPT_MODE, nonce, plainText);\n\t\t\tvar tag = doSha256Hash(key, encryptedData, additionalAuthenticatedData);\n\n\t\t\treturn ByteBuffer.allocate(encryptedData.length + TAG_SIZE)\n\t\t\t\t\t.put(encryptedData)\n\t\t\t\t\t.put(tag)\n\t\t\t\t\t.array();\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\t/**\n\t * Decrypts using ChaCha20 as an AEAD cipher with HMAC SHA-256.\n\t *\n\t * @param key                         the secret key, not null\n\t * @param nonce                       the unique, securely generated nonce that was used for the encryption, not null\n\t * @param cipherText                  the encrypted data, not null\n\t * @param additionalAuthenticatedData additional authenticated data. Is used to authenticate the nonce, not null\n\t * @return the decrypted data\n\t * @see <a href=\"https://datatracker.ietf.org/doc/html/rfc7539\">RFC 7539</a>\n\t */\n\tpublic static byte[] decryptChaCha20Sha256(SecretKey key, byte[] nonce, byte[] cipherText, byte[] additionalAuthenticatedData)\n\t{\n\t\tObjects.requireNonNull(key);\n\t\tObjects.requireNonNull(nonce);\n\t\tObjects.requireNonNull(cipherText);\n\t\tObjects.requireNonNull(additionalAuthenticatedData);\n\t\tif (nonce.length != 12)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Nonce must be 12 bytes\");\n\t\t}\n\n\t\tvar encryptedData = new byte[cipherText.length - TAG_SIZE];\n\t\tvar tag = new byte[TAG_SIZE];\n\n\t\tvar buf = ByteBuffer.wrap(cipherText);\n\t\tbuf.get(encryptedData);\n\t\tbuf.get(tag);\n\n\t\ttry\n\t\t{\n\t\t\tvar decryptedData = doChaCha20(key, DECRYPT_MODE, nonce, encryptedData);\n\t\t\t// Verify the SHA256 tag, this is performed after the decryption to avoid timing attacks.\n\t\t\tvar resultingTag = doSha256Hash(key, encryptedData, additionalAuthenticatedData);\n\n\t\t\tif (!MessageDigest.isEqual(tag, resultingTag))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"ChaCha20 SHA-256: Authentication failed\");\n\t\t\t}\n\t\t\treturn decryptedData;\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\tprivate static byte[] doChaCha20(SecretKey key, int operation, byte[] nonce, byte[] dataIn) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException\n\t{\n\t\tvar cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION_CHACHA20);\n\t\tvar chaCha20ParameterSpec = new ChaCha20ParameterSpec(nonce, 1);\n\t\tvar keySpec = new SecretKeySpec(key.getEncoded(), ENCRYPTION_ALGORITHM_CHACHA20);\n\t\tcipher.init(operation, keySpec, chaCha20ParameterSpec);\n\t\treturn cipher.doFinal(dataIn);\n\t}\n\n\tprivate static byte[] doSha256Hash(SecretKey key, byte[] encryptedData, byte[] additionalAuthenticatedData)\n\t{\n\t\tvar tag = new byte[TAG_SIZE];\n\t\tvar hmac = new Sha256HMac(key);\n\t\thmac.update(additionalAuthenticatedData);\n\t\thmac.update(encryptedData);\n\t\tSystem.arraycopy(hmac.getBytes(), 0, tag, 0, TAG_SIZE);\n\t\treturn tag;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/aes/AES.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.aes;\n\nimport javax.crypto.BadPaddingException;\nimport javax.crypto.Cipher;\nimport javax.crypto.IllegalBlockSizeException;\nimport javax.crypto.NoSuchPaddingException;\nimport javax.crypto.spec.IvParameterSpec;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.security.InvalidAlgorithmParameterException;\nimport java.security.InvalidKeyException;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Objects;\n\n/**\n * AES 256 CBC encryption.\n */\npublic final class AES\n{\n\tprivate static final String ALGORITHM_AES = \"AES/CBC/PKCS5Padding\";\n\tprivate static final int ROUNDS = 5;\n\tprivate static final int KEY_SIZE = 256; // in bits\n\tprivate static final int INDEX_KEY = 0;\n\tprivate static final int INDEX_IV = 1;\n\tprivate static final int IV_SIZE = 8; // in bytes\n\n\tprivate AES()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Encrypts using AES with a 16-byte key and an 8-byte salt.\n\t *\n\t * @param key       the 16-byte key\n\t * @param iv        an 8-byte initialization vector\n\t * @param plainText the plain text\n\t * @return the encoded text\n\t */\n\tpublic static byte[] encrypt(byte[] key, byte[] iv, byte[] plainText)\n\t{\n\t\treturn process(Cipher.ENCRYPT_MODE, key, iv, plainText);\n\t}\n\n\t/**\n\t * Decrypts using AES with a 16-byte key and an 8-byte salt.\n\t *\n\t * @param key           the 16-byte key\n\t * @param iv            an 8-byte initialization vector\n\t * @param encryptedText the encrypted text\n\t * @return the plain text\n\t */\n\tpublic static byte[] decrypt(byte[] key, byte[] iv, byte[] encryptedText)\n\t{\n\t\treturn process(Cipher.DECRYPT_MODE, key, iv, encryptedText);\n\t}\n\n\tprivate static byte[] process(int opMode, byte[] key, byte[] iv, byte[] data)\n\t{\n\t\tObjects.requireNonNull(key);\n\t\tObjects.requireNonNull(iv);\n\t\tObjects.requireNonNull(data);\n\n\t\tif (key.length != 16)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Invalid key\");\n\t\t}\n\n\t\tif (iv.length != IV_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Invalid salt\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tvar cipher = Cipher.getInstance(ALGORITHM_AES);\n\t\t\tvar md = MessageDigest.getInstance(\"SHA-1\");\n\n\t\t\tbyte[][] keyAndIv = EVP_BytesToKey(KEY_SIZE / Byte.SIZE, cipher.getBlockSize(), md, iv, key, ROUNDS);\n\n\t\t\tif (keyAndIv[INDEX_KEY].length != KEY_SIZE / Byte.SIZE)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Key size is \" + keyAndIv[INDEX_KEY].length + \" bits, should be \" + KEY_SIZE);\n\t\t\t}\n\n\t\t\tvar secretKeySpecs = new SecretKeySpec(keyAndIv[INDEX_KEY], \"AES\");\n\t\t\tvar ivParameterSpecs = new IvParameterSpec(keyAndIv[INDEX_IV]);\n\n\t\t\tcipher.init(opMode, secretKeySpecs, ivParameterSpecs);\n\t\t\treturn cipher.doFinal(data);\n\t\t}\n\t\tcatch (NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | BadPaddingException | InvalidKeyException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\n\t/**\n\t * OpenSSL equivalent, by Ola Bini, public domain. The source\n\t * is <a href=\"http://olabini.com/blog/tag/evp_bytestokey/\">here</a>.\n\t */\n\t@SuppressWarnings(\"SameParameterValue\")\n\tprivate static byte[][] EVP_BytesToKey(int keyLength, int ivLength, MessageDigest md, byte[] salt, byte[] data, int count)\n\t{\n\t\tvar key = new byte[keyLength];\n\t\tvar iv = new byte[ivLength];\n\t\tbyte[] mdBuf = null;\n\n\t\tvar keyPos = 0;\n\t\tvar ivPos = 0;\n\n\t\twhile (keyPos < keyLength || ivPos < ivLength)\n\t\t{\n\t\t\tmd.reset();\n\n\t\t\t// Include previous hash if not the first iteration\n\t\t\tif (mdBuf != null)\n\t\t\t{\n\t\t\t\tmd.update(mdBuf);\n\t\t\t}\n\n\t\t\tmd.update(data);\n\t\t\tmd.update(salt, 0, 8);\n\n\t\t\tmdBuf = md.digest();\n\n\t\t\t// Apply count iterations\n\t\t\tfor (var i = 1; i < count; i++)\n\t\t\t{\n\t\t\t\tmd.reset();\n\t\t\t\tmd.update(mdBuf);\n\t\t\t\tmdBuf = md.digest();\n\t\t\t}\n\n\t\t\t// Fill key material\n\t\t\tvar bufPos = 0;\n\t\t\twhile (keyPos < keyLength && bufPos < mdBuf.length)\n\t\t\t{\n\t\t\t\tkey[keyPos++] = mdBuf[bufPos++];\n\t\t\t}\n\n\t\t\t// Fill IV material\n\t\t\twhile (ivPos < ivLength && bufPos < mdBuf.length)\n\t\t\t{\n\t\t\t\tiv[ivPos++] = mdBuf[bufPos++];\n\t\t\t}\n\t\t}\n\t\treturn new byte[][]{key, iv};\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/dh/DiffieHellman.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.dh;\n\nimport javax.crypto.KeyAgreement;\nimport javax.crypto.spec.DHParameterSpec;\nimport javax.crypto.spec.DHPublicKeySpec;\nimport java.math.BigInteger;\nimport java.security.*;\nimport java.security.spec.InvalidKeySpecException;\n\nimport io.xeres.common.util.SecureRandomUtils;\n\npublic final class DiffieHellman\n{\n\tprivate static final String KEY_ALGORITHM = \"DH\";\n\n\t// Those values are used by Retroshare (P is 2048 bits group, generated by OpenSSL)\n\tstatic final BigInteger P = new BigInteger(\"B3B86A844550486C7EA459FA468D3A8EFD71139593FE1C658BBEFA9B2FC0AD2628242C2CDC2F91F5B220ED29AAC271192A7374DFA28CDDCA70252F342D0821273940344A7A6A3CB70C7897A39864309F6CAC5C7EA18020EF882693CA2C12BB211B7BA8367D5A7C7252A5B5E840C9E8F081469EBA0B98BCC3F593A4D9C4D5DF539362084F1B9581316C1F80FDAD452FD56DBC6B8ED0775F596F7BB22A3FE2B4753764221528D33DB4140DE58083DB660E3E105123FC963BFF108AC3A268B7380FFA72005A1515C371287C5706FFA6062C9AC73A9B1A6AC842C2764CDACFC85556607E86611FDF486C222E4896CDF6908F239E177ACC641FCBFF72A758D1C10CBB\", 16);\n\tstatic final BigInteger G = new BigInteger(\"5\", 16);\n\n\tprivate DiffieHellman()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static KeyPair generateKeys()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM);\n\t\t\tvar dhParameterSpec = new DHParameterSpec(P, G);\n\n\t\t\tkeyPairGenerator.initialize(dhParameterSpec, SecureRandomUtils.getGenerator());\n\n\t\t\treturn keyPairGenerator.generateKeyPair();\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"DH algorithm error: \" + e.getMessage());\n\t\t}\n\t}\n\n\tpublic static PublicKey getPublicKey(BigInteger pubKeyBigInteger)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);\n\n\t\t\treturn keyFactory.generatePublic(new DHPublicKeySpec(pubKeyBigInteger, P, G));\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeySpecException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"DH algorithm error: \" + e.getMessage());\n\t\t}\n\n\t}\n\n\tpublic static byte[] generateCommonSecretKey(PrivateKey privateKey, PublicKey receivedPublicKey)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar keyAgreement = KeyAgreement.getInstance(KEY_ALGORITHM);\n\t\t\tkeyAgreement.init(privateKey);\n\t\t\tkeyAgreement.doPhase(receivedPublicKey, true);\n\n\t\t\treturn keyAgreement.generateSecret();\n\t\t}\n\t\tcatch (InvalidKeyException | NoSuchAlgorithmException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"DH algorithm error: \" + e.getMessage());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/ec/Ed25519.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.ec;\n\nimport java.security.KeyPair;\nimport java.security.KeyPairGenerator;\nimport java.security.NoSuchAlgorithmException;\n\npublic final class Ed25519\n{\n\tprivate static final String KEY_ALGORITHM = \"Ed25519\";\n\n\tprivate Ed25519()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static KeyPair generateKeys(int size)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM);\n\n\t\t\tkeyPairGenerator.initialize(size);\n\n\t\t\treturn keyPairGenerator.generateKeyPair();\n\t\t}\n\t\tcatch (NoSuchAlgorithmException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Algorithm not supported\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hash/AbstractMessageDigest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hash;\n\nimport java.nio.ByteBuffer;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\n\npublic abstract class AbstractMessageDigest\n{\n\tprotected final MessageDigest messageDigest;\n\tprivate byte[] result;\n\n\tprotected AbstractMessageDigest(String algorithm)\n\t{\n\t\ttry\n\t\t{\n\t\t\tmessageDigest = MessageDigest.getInstance(algorithm);\n\t\t}\n\t\tcatch (NoSuchAlgorithmException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic void update(byte[] input)\n\t{\n\t\tresetCompletion();\n\t\tmessageDigest.update(input);\n\t}\n\n\tpublic void update(byte[] input, int offset, int length)\n\t{\n\t\tresetCompletion();\n\t\tmessageDigest.update(input, offset, length);\n\t}\n\n\tpublic void update(ByteBuffer input)\n\t{\n\t\tresetCompletion();\n\t\tmessageDigest.update(input);\n\t}\n\n\tpublic byte[] getBytes()\n\t{\n\t\tcompleteIfNeeded();\n\t\treturn result;\n\t}\n\n\tprivate void completeIfNeeded()\n\t{\n\t\tif (result == null)\n\t\t{\n\t\t\tresult = messageDigest.digest();\n\t\t}\n\t}\n\n\tprivate void resetCompletion()\n\t{\n\t\tresult = null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hash/chat/ChatChallenge.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hash.chat;\n\nimport io.xeres.common.id.Identifier;\n\n/**\n * Utility class to handle challenge codes, which allows peers to know if they\n * have a common private chat room without disclosing it first.\n */\npublic final class ChatChallenge\n{\n\tprivate ChatChallenge()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static long code(Identifier identifier, long chatRoomId, long messageId)\n\t{\n\t\tlong code = 0;\n\n\t\tvar id = identifier.getBytes();\n\n\t\tfor (var i = 0; i < identifier.getLength(); i++)\n\t\t{\n\t\t\tcode += messageId;\n\t\t\tcode ^= code >>> 35;\n\t\t\tcode += code << 6;\n\t\t\tcode ^= Byte.toUnsignedLong(id[i]) * chatRoomId;\n\t\t\tcode += code << 26;\n\t\t\tcode ^= code >>> 13;\n\t\t}\n\t\treturn code;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hash/sha1/Sha1MessageDigest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hash.sha1;\n\nimport io.xeres.app.crypto.hash.AbstractMessageDigest;\nimport io.xeres.common.id.Sha1Sum;\n\npublic class Sha1MessageDigest extends AbstractMessageDigest\n{\n\tpublic Sha1MessageDigest()\n\t{\n\t\tsuper(\"SHA-1\");\n\t}\n\n\tpublic Sha1Sum getSum()\n\t{\n\t\treturn new Sha1Sum(getBytes());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hash/sha256/Sha256MessageDigest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hash.sha256;\n\nimport io.xeres.app.crypto.hash.AbstractMessageDigest;\n\npublic class Sha256MessageDigest extends AbstractMessageDigest\n{\n\tpublic Sha256MessageDigest()\n\t{\n\t\tsuper(\"SHA-256\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hmac/AbstractHMac.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hmac;\n\nimport javax.crypto.Mac;\nimport javax.crypto.SecretKey;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.nio.ByteBuffer;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\n\npublic abstract class AbstractHMac\n{\n\tprotected final Mac mac;\n\tprivate byte[] result;\n\n\tprotected AbstractHMac(SecretKey secretKey, String algorithm)\n\t{\n\t\ttry\n\t\t{\n\t\t\tmac = Mac.getInstance(algorithm);\n\t\t\tmac.init(new SecretKeySpec(secretKey.getEncoded(), algorithm));\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeyException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic void update(byte[] input)\n\t{\n\t\tresetCompletion();\n\t\tmac.update(input);\n\t}\n\n\tpublic void update(byte[] input, int offset, int length)\n\t{\n\t\tresetCompletion();\n\t\tmac.update(input, offset, length);\n\t}\n\n\tpublic void update(ByteBuffer input)\n\t{\n\t\tresetCompletion();\n\t\tmac.update(input);\n\t}\n\n\tpublic byte[] getBytes()\n\t{\n\t\tcompleteIfNeeded();\n\t\treturn result;\n\t}\n\n\tprivate void completeIfNeeded()\n\t{\n\t\tif (result == null)\n\t\t{\n\t\t\tresult = mac.doFinal();\n\t\t}\n\t}\n\n\tprivate void resetCompletion()\n\t{\n\t\tresult = null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hmac/sha1/Sha1HMac.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hmac.sha1;\n\nimport io.xeres.app.crypto.hmac.AbstractHMac;\n\nimport javax.crypto.SecretKey;\n\npublic class Sha1HMac extends AbstractHMac\n{\n\tpublic Sha1HMac(SecretKey secretKey)\n\t{\n\t\tsuper(secretKey, \"HmacSHA1\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/hmac/sha256/Sha256HMac.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hmac.sha256;\n\nimport io.xeres.app.crypto.hmac.AbstractHMac;\n\nimport javax.crypto.SecretKey;\n\npublic class Sha256HMac extends AbstractHMac\n{\n\tpublic Sha256HMac(SecretKey secretKey)\n\t{\n\t\tsuper(secretKey, \"HmacSHA256\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/pgp/PGP.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.pgp;\n\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.common.util.SecureRandomUtils;\nimport org.bouncycastle.bcpg.ArmoredOutputStream;\nimport org.bouncycastle.bcpg.BCPGOutputStream;\nimport org.bouncycastle.bcpg.PublicKeyPacket;\nimport org.bouncycastle.bcpg.SignaturePacket;\nimport org.bouncycastle.openpgp.*;\nimport org.bouncycastle.openpgp.jcajce.JcaPGPPublicKeyRingCollection;\nimport org.bouncycastle.openpgp.operator.PGPContentSignerBuilder;\nimport org.bouncycastle.openpgp.operator.jcajce.*;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.nio.ByteBuffer;\nimport java.security.InvalidKeyException;\nimport java.security.KeyPair;\nimport java.security.SignatureException;\nimport java.util.Date;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.Objects;\n\nimport static io.xeres.common.Features.EXPERIMENTAL_EC;\nimport static org.bouncycastle.bcpg.HashAlgorithmTags.*;\nimport static org.bouncycastle.bcpg.PublicKeyAlgorithmTags.DSA;\nimport static org.bouncycastle.bcpg.PublicKeyAlgorithmTags.Ed25519;\nimport static org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags.AES_128;\nimport static org.bouncycastle.openpgp.PGPPublicKey.RSA_GENERAL;\nimport static org.bouncycastle.openpgp.PGPSignature.BINARY_DOCUMENT;\nimport static org.bouncycastle.openpgp.PGPSignature.DEFAULT_CERTIFICATION;\n\n/**\n * Utility class containing all PGP related methods.\n */\npublic final class PGP\n{\n\tprivate PGP()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic enum Armor\n\t{\n\t\tNONE,\n\t\tBASE64\n\t}\n\n\t/**\n\t * Gets the PGP public key as an armored (ASCII) key.\n\t *\n\t * @param pgpPublicKey the public key\n\t * @param out the output stream\n\t * @throws IOException if three's an I/O error\n\t */\n\tpublic static void getPublicKeyArmored(PGPPublicKey pgpPublicKey, OutputStream out) throws IOException\n\t{\n\t\tgetPublicKeyArmored(pgpPublicKey.getEncoded(true), out);\n\t}\n\n\t/**\n\t * Gets the PGP public key as an armored (ASCII) key.\n\t *\n\t * @param data the public key as a byte array\n\t * @param out the output stream\n\t * @throws IOException if there's an I/O error\n\t */\n\tpublic static void getPublicKeyArmored(byte[] data, OutputStream out) throws IOException\n\t{\n\t\tvar aOut = new ArmoredOutputStream(out);\n\n\t\tvar pgpObjectFactory = new PGPObjectFactory(data, new JcaKeyFingerprintCalculator());\n\n\t\tvar object = pgpObjectFactory.nextObject();\n\n\t\tif (object instanceof PGPPublicKeyRing pgpPublicKeyRing)\n\t\t{\n\t\t\tfor (var publicKey : pgpPublicKeyRing)\n\t\t\t{\n\t\t\t\tpublicKey.encode(aOut);\n\t\t\t\taOut.close();\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Wrong encoded key structure: \" + object.getClass().getCanonicalName());\n\t\t}\n\t}\n\n\t/**\n\t * Gets the PGP secret key. While a secret key needs a password to be converted to a private\n\t * key, this implementation uses an empty password.\n\t *\n\t * @param data a byte array containing the raw PGP key\n\t * @return the {@link PGPSecretKey}\n\t * @throws IllegalArgumentException if the key is wrong\n\t */\n\tpublic static PGPSecretKey getPGPSecretKey(byte[] data)\n\t{\n\t\tvar pgpObjectFactory = new PGPObjectFactory(data, new JcaKeyFingerprintCalculator());\n\n\t\ttry\n\t\t{\n\t\t\tvar object = pgpObjectFactory.nextObject();\n\n\t\t\tif (object instanceof PGPSecretKeyRing pgpSecretKeyRing)\n\t\t\t{\n\t\t\t\tif (!pgpSecretKeyRing.iterator().hasNext())\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"PGPSecretKeyRing is empty\");\n\t\t\t\t}\n\t\t\t\treturn pgpSecretKeyRing.iterator().next();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"PGPSecretKeyRing expected, got: \" + object.getClass().getCanonicalName() + \" instead\");\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"PGPSecretKeyRing is corrupted\", e);\n\t\t}\n\t}\n\n\t/**\n\t * Gets the PGP public key.\n\t *\n\t * @param data a byte array containing the raw PGP key\n\t * @return the {@link PGPPublicKey}\n\t * @throws InvalidKeyException if the key is wrong\n\t */\n\tpublic static PGPPublicKey getPGPPublicKey(byte[] data) throws InvalidKeyException\n\t{\n\t\tvar pgpObjectFactory = new PGPObjectFactory(data, new JcaKeyFingerprintCalculator());\n\n\t\ttry\n\t\t{\n\t\t\tvar object = pgpObjectFactory.nextObject();\n\n\t\t\tif (object instanceof PGPPublicKeyRing pgpPublicKeyRing)\n\t\t\t{\n\t\t\t\tif (!pgpPublicKeyRing.iterator().hasNext())\n\t\t\t\t{\n\t\t\t\t\tthrow new InvalidKeyException(\"PGPPublicKeyRing is empty\");\n\t\t\t\t}\n\t\t\t\treturn pgpPublicKeyRing.iterator().next();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tthrow new InvalidKeyException(\"PGPPublicKeyRing expected, got: \" + object.getClass().getCanonicalName() + \" instead\");\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new InvalidKeyException(\"PGPPublicKeyRing is corrupted\", e);\n\t\t}\n\t}\n\n\t/**\n\t * Generates a PGP secret key.\n\t * <p>\n\t * The key is a PGP <b>V4</b> format, <b>RSA</b> key with a <b>default certification</b>,\n\t * <b>SHA-256</b> integrity checksum and encrypted with <b>AES-128</b>. The packet sizes are encoded using the original format.\n\t * <p>\n\t * This was changed from the previous key format that used SHA-1 because RNP which will be used by the next Retroshare uses SHA-256. The previous version also used CAST5 as encryption.\n\t *\n\t * @param id     the id of the key\n\t * @param suffix the suffix appended to the id\n\t * @param size   the size of the key\n\t * @return the {@link PGPSecretKey}\n\t * @throws PGPException if somehow the PGP key generation failed (for example, wrong key size)\n\t */\n\tpublic static PGPSecretKey generateSecretKey(String id, String suffix, int size) throws PGPException\n\t{\n\t\tKeyPair keyPair;\n\n\t\tif (EXPERIMENTAL_EC)\n\t\t{\n\t\t\tkeyPair = io.xeres.app.crypto.ec.Ed25519.generateKeys(size);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tkeyPair = RSA.generateKeys(size);\n\t\t}\n\n\t\tPGPKeyPair pgpKeyPair = new JcaPGPKeyPair(EXPERIMENTAL_EC ? PublicKeyPacket.VERSION_6 : PublicKeyPacket.VERSION_4, EXPERIMENTAL_EC ? Ed25519 : RSA_GENERAL, keyPair, new Date());\n\n\t\treturn encryptKeyPair(pgpKeyPair, suffix != null ? (id + \" \" + suffix) : id);\n\t}\n\n\tpublic static PGPSecretKey encryptKeyPair(PGPKeyPair pgpKeyPair, String id) throws PGPException\n\t{\n\t\tvar shaCalc = new JcaPGPDigestCalculatorProviderBuilder().build().get(SHA1);\n\t\tvar signer = new JcaPGPContentSignerBuilder(pgpKeyPair.getPublicKey().getAlgorithm(), SHA256);\n\t\tvar encryptor = new JcePBESecretKeyEncryptorBuilder(AES_128, shaCalc).setSecureRandom(SecureRandomUtils.getGenerator()).build(\"\".toCharArray());\n\n\t\treturn new PGPSecretKey(pgpKeyPair.getPrivateKey(), certifiedPublicKey(pgpKeyPair, id, signer), shaCalc, true, encryptor);\n\t}\n\n\tprivate static PGPPublicKey certifiedPublicKey(PGPKeyPair keyPair, String id, PGPContentSignerBuilder certificationSignerBuilder) throws PGPException\n\t{\n\t\tvar signatureGenerator = new PGPSignatureGenerator(certificationSignerBuilder, keyPair.getPublicKey(), EXPERIMENTAL_EC ? SignaturePacket.VERSION_6 : SignaturePacket.VERSION_4);\n\n\t\tsignatureGenerator.init(DEFAULT_CERTIFICATION, keyPair.getPrivateKey());\n\n\t\tsignatureGenerator.setHashedSubpackets(null);\n\t\tsignatureGenerator.setUnhashedSubpackets(null);\n\n\t\tvar certification = signatureGenerator.generateCertification(id, keyPair.getPublicKey());\n\t\treturn PGPPublicKey.addCertification(keyPair.getPublicKey(), id, certification);\n\t}\n\n\t/**\n\t * Signs a message as a <b>binary document</b> using <b>SHA-256</b>.\n\t *\n\t * @param pgpSecretKey the secret key to sign the message with\n\t * @param in           the message\n\t * @param out          the resulting PGP signature\n\t * @param armor        optional ASCII armoring (base 64 encoding)\n\t * @throws PGPException if there's a PGP error\n\t * @throws IOException  if there's an I/O error\n\t */\n\tpublic static void sign(PGPSecretKey pgpSecretKey, InputStream in, OutputStream out, Armor armor) throws PGPException, IOException\n\t{\n\t\tif (armor == Armor.BASE64)\n\t\t{\n\t\t\tout = new ArmoredOutputStream(out);\n\t\t}\n\n\t\tvar pgpPrivateKey = pgpSecretKey.extractPrivateKey(new JcePBESecretKeyDecryptorBuilder()\n\t\t\t\t.build(\"\".toCharArray()));\n\n\t\tvar signatureGenerator = new PGPSignatureGenerator(new JcaPGPContentSignerBuilder(pgpSecretKey.getPublicKey().getAlgorithm(), SHA256), pgpSecretKey.getPublicKey(), EXPERIMENTAL_EC ? SignaturePacket.VERSION_6 : SignaturePacket.VERSION_4);\n\n\t\tsignatureGenerator.init(BINARY_DOCUMENT, pgpPrivateKey);\n\n\t\tvar bOut = new BCPGOutputStream(out);\n\n\t\tsignatureGenerator.update(in.readAllBytes());\n\t\tin.close();\n\n\t\tsignatureGenerator.generate().encode(bOut);\n\n\t\tif (armor == Armor.BASE64)\n\t\t{\n\t\t\tout.close();\n\t\t}\n\t}\n\n\t/**\n\t * Verifies a PGP signature.\n\t * <p>\n\t * Note that only a handful of algorithms are supported.\n\t *\n\t * @param pgpPublicKey the public key corresponding to the private key used to generate the signature\n\t * @param signature    the signature\n\t * @param in           the message\n\t * @throws SignatureException if the message verification failed\n\t * @throws IOException        if there's an I/O error\n\t * @throws PGPException       if there's a PGP error\n\t */\n\tpublic static void verify(PGPPublicKey pgpPublicKey, byte[] signature, InputStream in) throws IOException, SignatureException, PGPException\n\t{\n\t\tvar pgpSignature = getSignature(signature);\n\n\t\tpgpSignature.init(new JcaPGPContentVerifierBuilderProvider(), pgpPublicKey);\n\t\tpgpSignature.update(in.readAllBytes());\n\t\tin.close();\n\t\tif (!pgpSignature.verify())\n\t\t{\n\t\t\tthrow new SignatureException(\"Wrong signature\");\n\t\t}\n\t}\n\n\tpublic static long getIssuer(byte[] signature)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar pgpSignature = getSignature(signature);\n\t\t\treturn pgpSignature.getKeyID();\n\t\t}\n\t\tcatch (SignatureException | IOException _)\n\t\t{\n\t\t\treturn 0L;\n\t\t}\n\t}\n\n\t/**\n\t * Gets the public key used for signing releases.\n\t *\n\t * @return the signing key\n\t * @throws IOException  if I/O error\n\t * @throws PGPException if the key is somehow wrong\n\t */\n\tpublic static PGPPublicKey getUpdateSigningKey() throws IOException, PGPException\n\t{\n\t\tInputStream in = Objects.requireNonNull(PGP.class.getResourceAsStream(\"/public.asc\"));\n\n\t\tJcaPGPPublicKeyRingCollection publicKeyRingCollection;\n\n\t\tin = PGPUtil.getDecoderStream(in);\n\n\t\tpublicKeyRingCollection = new JcaPGPPublicKeyRingCollection(in);\n\t\tin.close();\n\n\t\tPGPPublicKey publicKey = null;\n\t\tIterator<PGPPublicKeyRing> keyRings = publicKeyRingCollection.getKeyRings();\n\t\twhile (publicKey == null && keyRings.hasNext())\n\t\t{\n\t\t\tPGPPublicKeyRing keyRing = keyRings.next();\n\t\t\tIterator<PGPPublicKey> publicKeys = keyRing.getPublicKeys();\n\t\t\twhile (publicKey == null && publicKeys.hasNext())\n\t\t\t{\n\t\t\t\tPGPPublicKey k = publicKeys.next();\n\n\t\t\t\tif (k.isEncryptionKey())\n\t\t\t\t{\n\t\t\t\t\tpublicKey = k;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (publicKey == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Release signing public key not found\");\n\t\t}\n\t\treturn publicKey;\n\t}\n\n\tprivate static PGPSignature getSignature(byte[] signature) throws SignatureException, IOException\n\t{\n\t\tvar pgpObjectFactory = new PGPObjectFactory(signature, new JcaKeyFingerprintCalculator());\n\n\t\tvar object = pgpObjectFactory.nextObject();\n\t\tif (!(object instanceof PGPSignatureList pgpSignatures))\n\t\t{\n\t\t\tthrow new SignatureException(\"Signature doesn't contain a PGP signature list\");\n\t\t}\n\t\tif (pgpSignatures.isEmpty())\n\t\t{\n\t\t\tthrow new SignatureException(\"Signature list empty\");\n\t\t}\n\n\t\tvar pgpSignature = pgpSignatures.get(0);\n\n\t\tif (pgpSignature.getSignatureType() != BINARY_DOCUMENT)\n\t\t{\n\t\t\tthrow new SignatureException(\"Signature is not of BINARY_DOCUMENT (\" + pgpSignature.getSignatureType() + \")\");\n\t\t}\n\n\t\tif (pgpSignature.getVersion() != 4 && pgpSignature.getVersion() != 6)\n\t\t{\n\t\t\tthrow new SignatureException(\"Signature is not PGP version 4 or 6 (\" + pgpSignature.getVersion() + \")\");\n\t\t}\n\n\t\tif (!List.of(RSA_GENERAL, 3 /* RSA_SIGN */, DSA, Ed25519).contains(pgpSignature.getKeyAlgorithm()))\n\t\t{\n\t\t\tthrow new SignatureException(\"Signature key algorithm is not of RSA, DSA or Ed25519 (\" + pgpSignature.getSignatureType() + \")\");\n\t\t}\n\n\t\tif (!List.of(SHA1, SHA256, SHA384, SHA512).contains(pgpSignature.getHashAlgorithm()))\n\t\t{\n\t\t\tthrow new SignatureException(\"Signature hash algorithm is not of SHA family (\" + pgpSignature.getHashAlgorithm() + \")\");\n\t\t}\n\t\treturn pgpSignature;\n\t}\n\n\t/**\n\t * Gets the PGP identifier, which is the last long of the PGP fingerprint\n\t *\n\t * @return the PGP identifier\n\t */\n\tpublic static long getPGPIdentifierFromFingerprint(byte[] fingerprint)\n\t{\n\t\tvar buf = ByteBuffer.allocate(Long.BYTES);\n\t\tif (fingerprint.length == 20)\n\t\t{\n\t\t\tbuf.put(fingerprint, 12, 8);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbuf.put(fingerprint, 0, 8);\n\t\t}\n\t\tbuf.flip();\n\t\treturn buf.getLong();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/pgp/PGPSigner.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.pgp;\n\nimport org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;\nimport org.bouncycastle.asn1.x509.AlgorithmIdentifier;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.bouncycastle.operator.ContentSigner;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.OutputStream;\n\nimport static io.xeres.app.crypto.pgp.PGP.Armor;\nimport static io.xeres.app.crypto.pgp.PGP.sign;\n\npublic class PGPSigner implements ContentSigner\n{\n\tprivate final ByteArrayOutputStream outputStream;\n\tprivate final PGPSecretKey pgpSecretKey;\n\n\tpublic PGPSigner(PGPSecretKey pgpSecretKey)\n\t{\n\t\tthis.pgpSecretKey = pgpSecretKey;\n\t\toutputStream = new ByteArrayOutputStream();\n\t}\n\n\t@Override\n\tpublic AlgorithmIdentifier getAlgorithmIdentifier()\n\t{\n\t\treturn new AlgorithmIdentifier(PKCSObjectIdentifiers.sha256WithRSAEncryption);\n\t}\n\n\t@Override\n\tpublic OutputStream getOutputStream()\n\t{\n\t\treturn outputStream;\n\t}\n\n\t@Override\n\tpublic byte[] getSignature()\n\t{\n\t\ttry (var out = new ByteArrayOutputStream())\n\t\t{\n\t\t\tsign(pgpSecretKey, new ByteArrayInputStream(outputStream.toByteArray()), out, Armor.NONE);\n\t\t\toutputStream.close();\n\n\t\t\treturn out.toByteArray();\n\t\t}\n\t\tcatch (PGPException | IOException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Failed to sign certificate: \" + e.getMessage(), e.getCause());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/pgp/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * PGP related functions. Used for creating the private and public PGP keys\n * which identify one profile, also known as a user. Locations' certificates are then signed using\n * the <i>private key</i>.<p>\n * The <i>public key</i> is distributed to other profiles so that they can verify the location's certificate\n * signature.\n *\n * @see <a href=\"https://tools.ietf.org/html/rfc4880\">RFC 4880</a>\n */\npackage io.xeres.app.crypto.pgp;\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsa/RSA.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsa;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.common.annotation.RsDeprecated;\nimport io.xeres.common.id.GxsId;\nimport org.bouncycastle.asn1.ASN1InputStream;\nimport org.bouncycastle.asn1.DERNull;\nimport org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;\nimport org.bouncycastle.asn1.pkcs.PrivateKeyInfo;\nimport org.bouncycastle.asn1.x509.AlgorithmIdentifier;\nimport org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;\nimport org.bouncycastle.util.BigIntegers;\n\nimport java.io.IOException;\nimport java.security.*;\nimport java.security.interfaces.RSAPublicKey;\nimport java.security.spec.InvalidKeySpecException;\nimport java.security.spec.PKCS8EncodedKeySpec;\nimport java.security.spec.X509EncodedKeySpec;\nimport java.util.Arrays;\nimport java.util.Objects;\n\n/**\n * Implements all RSA related functions. Used for creating the private and public SSL keys\n * which identify one location, also known as a machine or node.\n */\npublic final class RSA\n{\n\tprivate static final String KEY_ALGORITHM = \"RSA\";\n\tprivate static final String SIGNATURE_ALGORITHM = \"SHA1withRSA\"; // SHA1 is needed for Retroshare compatibility\n\n\tprivate RSA()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Generates an RSA private/public key pair.\n\t *\n\t * @param size the key size (512, 1024, 2048, 3072, 4096, etc...)\n\t * @return the key pair\n\t */\n\tpublic static KeyPair generateKeys(int size)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM);\n\n\t\t\tkeyPairGenerator.initialize(size);\n\n\t\t\treturn keyPairGenerator.generateKeyPair();\n\t\t}\n\t\tcatch (NoSuchAlgorithmException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Algorithm not supported\");\n\t\t}\n\t}\n\n\t/**\n\t * Gets the RSA public key from the encoded form.\n\t *\n\t * @param data the public key in encoded bytes\n\t * @return the public key\n\t * @throws NoSuchAlgorithmException if the RSA algorithm is unavailable\n\t * @throws InvalidKeySpecException  if it's not an RSA key\n\t */\n\tpublic static PublicKey getPublicKey(byte[] data) throws NoSuchAlgorithmException, InvalidKeySpecException\n\t{\n\t\tObjects.requireNonNull(data);\n\t\treturn KeyFactory.getInstance(KEY_ALGORITHM).generatePublic(new X509EncodedKeySpec(data));\n\t}\n\n\t/**\n\t * Gets the RSA private key from the encoded form.\n\t *\n\t * @param data the private key in encoded bytes\n\t * @return the private key\n\t * @throws NoSuchAlgorithmException if the RSA algorithm is unavailable\n\t * @throws InvalidKeySpecException  if it's not an RSA key\n\t */\n\tpublic static PrivateKey getPrivateKey(byte[] data) throws NoSuchAlgorithmException, InvalidKeySpecException\n\t{\n\t\tObjects.requireNonNull(data);\n\t\treturn KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(new PKCS8EncodedKeySpec(data));\n\t}\n\n\t/**\n\t * Signs some data.\n\t *\n\t * @param privateKey the RSA private key\n\t * @param data       the data to sign\n\t * @return the signature\n\t */\n\tpublic static byte[] sign(PrivateKey privateKey, byte[] data)\n\t{\n\t\tObjects.requireNonNull(privateKey);\n\t\tObjects.requireNonNull(data);\n\t\ttry\n\t\t{\n\t\t\tvar signer = Signature.getInstance(SIGNATURE_ALGORITHM);\n\t\t\tsigner.initSign(privateKey);\n\t\t\tsigner.update(data);\n\t\t\treturn signer.sign();\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\t}\n\n\t/**\n\t * Verifies signed data.\n\t *\n\t * @param publicKey the RSA public key\n\t * @param signature the signature\n\t * @param data      the data to verify\n\t * @return true if verification is successful\n\t */\n\tpublic static boolean verify(PublicKey publicKey, byte[] signature, byte[] data)\n\t{\n\t\tObjects.requireNonNull(publicKey);\n\t\tObjects.requireNonNull(signature);\n\t\tObjects.requireNonNull(data);\n\t\ttry\n\t\t{\n\t\t\tvar signer = Signature.getInstance(SIGNATURE_ALGORITHM);\n\t\t\tsigner.initVerify(publicKey);\n\t\t\tsigner.update(data);\n\t\t\treturn signer.verify(signature);\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | SignatureException | InvalidKeyException _)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Converts an RSA private key from PKCS #8 to PKCS #1\n\t *\n\t * @param privateKey the RSA private key\n\t * @return the RSA private key in PKCS #8 format\n\t * @throws IOException if the key format is wrong\n\t */\n\tpublic static byte[] getPrivateKeyAsPkcs1(PrivateKey privateKey) throws IOException\n\t{\n\t\tObjects.requireNonNull(privateKey);\n\t\tvar privateKeyInfo = PrivateKeyInfo.getInstance(privateKey.getEncoded());\n\t\tvar encodable = privateKeyInfo.parsePrivateKey();\n\t\tvar primitive = encodable.toASN1Primitive();\n\t\treturn primitive.getEncoded();\n\t}\n\n\t/**\n\t * Converts a PKCS #1 byte array to an RSA private key\n\t *\n\t * @param data the DER encoded PKCS #1 byte array\n\t * @return an RSA private key\n\t * @throws IOException              if the key format is wrong\n\t * @throws NoSuchAlgorithmException if the key format is wrong\n\t * @throws InvalidKeySpecException  if the encoding is wrong\n\t */\n\tpublic static PrivateKey getPrivateKeyFromPkcs1(byte[] data) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException\n\t{\n\t\tObjects.requireNonNull(data);\n\t\ttry (var asn1InputStream = new ASN1InputStream(data))\n\t\t{\n\t\t\tvar asn1Primitive = asn1InputStream.readObject();\n\t\t\tvar algorithmIdentifier = new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE);\n\t\t\tvar privateKeyInfo = new PrivateKeyInfo(algorithmIdentifier, asn1Primitive);\n\t\t\treturn getPrivateKey(privateKeyInfo.getEncoded());\n\t\t}\n\t}\n\n\t/**\n\t * Converts an RSA public key from X.509 to PKCS #1\n\t *\n\t * @param publicKey the RSA public key\n\t * @return the RSA public key in PKCS #1 format\n\t * @throws IOException if the key format is wrong\n\t */\n\tpublic static byte[] getPublicKeyAsPkcs1(PublicKey publicKey) throws IOException\n\t{\n\t\tObjects.requireNonNull(publicKey);\n\t\tvar subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded());\n\t\tvar primitive = subjectPublicKeyInfo.parsePublicKey();\n\t\treturn primitive.getEncoded();\n\t}\n\n\t/**\n\t * Converts a PKCS #1 byte array to an RSA public key.\n\t *\n\t * @param data the DER encoded PKCS #1 byte array\n\t * @return an RSA public key\n\t * @throws IOException              if the key format is wrong\n\t * @throws NoSuchAlgorithmException if the key format is wrong\n\t * @throws InvalidKeySpecException  if the encoding is wrong\n\t */\n\tpublic static PublicKey getPublicKeyFromPkcs1(byte[] data) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException\n\t{\n\t\tObjects.requireNonNull(data);\n\t\tvar algorithmIdentifier = new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE);\n\t\tvar subjectPublicKeyInfo = new SubjectPublicKeyInfo(algorithmIdentifier, data);\n\t\treturn getPublicKey(subjectPublicKeyInfo.getEncoded());\n\t}\n\n\t/**\n\t * Computes the GxsId from the key. This is done by sha1 hashing the n and e numbers\n\t * and getting the first 16 bytes from it.\n\t *\n\t * @param publicKey the RSA public key\n\t * @return the GxsId\n\t */\n\tpublic static GxsId getGxsId(PublicKey publicKey)\n\t{\n\t\tObjects.requireNonNull(publicKey);\n\t\tvar rsaPublicKey = (RSAPublicKey) publicKey;\n\t\treturn makeGxsId(\n\t\t\t\tBigIntegers.asUnsignedByteArray(rsaPublicKey.getModulus()),\n\t\t\t\tBigIntegers.asUnsignedByteArray(rsaPublicKey.getPublicExponent())\n\t\t);\n\t}\n\n\tprivate static GxsId makeGxsId(byte[] modulus, byte[] exponent)\n\t{\n\t\tvar md = new Sha1MessageDigest();\n\t\tmd.update(modulus);\n\t\tmd.update(exponent);\n\n\t\t// Copy the first 16 bytes of the sha1 sum to get the GxsId\n\t\treturn new GxsId(Arrays.copyOfRange(md.getBytes(), 0, GxsId.LENGTH));\n\t}\n\n\t/**\n\t * Computes the GxsId from the key.\n\t * <p>\n\t * Note: For compatibility with entities generated by old Retroshare versions. Is less secure. Do not use for new code.\n\t *\n\t * @param publicKey the RSA public key\n\t * @return the GxsId\n\t */\n\t@RsDeprecated\n\tpublic static GxsId getGxsIdInsecure(PublicKey publicKey)\n\t{\n\t\tObjects.requireNonNull(publicKey);\n\t\tvar rsaPublicKey = (RSAPublicKey) publicKey;\n\t\treturn makeGxsIdInsecure(BigIntegers.asUnsignedByteArray(rsaPublicKey.getModulus()));\n\t}\n\n\tprivate static GxsId makeGxsIdInsecure(byte[] modulus)\n\t{\n\t\treturn new GxsId(Arrays.copyOfRange(modulus, 0, GxsId.LENGTH));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rscrypto/RsCrypto.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rscrypto;\n\nimport io.xeres.app.crypto.aead.AEAD;\nimport io.xeres.common.util.SecureRandomUtils;\n\nimport javax.crypto.SecretKey;\nimport java.nio.ByteBuffer;\n\n/**\n * This class implements the custom RS encryption, notably to encrypt file transfer tunnels.\n *  <p>\n *  <img src=\"doc-files/format.png\" alt=\"Format diagram\">\n */\npublic final class RsCrypto\n{\n\tpublic enum EncryptionFormat\n\t{\n\t\tCHACHA20_POLY1305(1),\n\t\tCHACHA20_SHA256(2);\n\n\t\tprivate final int value;\n\n\t\tEncryptionFormat(int value)\n\t\t{\n\t\t\tthis.value = value;\n\t\t}\n\n\t\tpublic int getValue()\n\t\t{\n\t\t\treturn value;\n\t\t}\n\t}\n\n\tprivate static final int INITIALIZATION_VECTOR_SIZE = 12;\n\tprivate static final int AUTHENTICATION_TAG_SIZE = 16;\n\tprivate static final int HEADER_SIZE = 4;\n\tprivate static final int EDATA_SIZE = 4;\n\n\tprivate RsCrypto()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static byte[] encryptAuthenticateData(SecretKey key, byte[] plainText, EncryptionFormat format)\n\t{\n\t\t// Initialization vector (AAD)\n\t\tvar initializationVector = new byte[INITIALIZATION_VECTOR_SIZE];\n\t\tSecureRandomUtils.nextBytes(initializationVector);\n\n\t\tvar aad = new byte[INITIALIZATION_VECTOR_SIZE + EDATA_SIZE];\n\t\tSystem.arraycopy(initializationVector, 0, aad, 0, INITIALIZATION_VECTOR_SIZE);\n\n\t\taad[INITIALIZATION_VECTOR_SIZE] = (byte) ((plainText.length) & 0xff);\n\t\taad[INITIALIZATION_VECTOR_SIZE + 1] = (byte) ((plainText.length >> 8) & 0xff);\n\t\taad[INITIALIZATION_VECTOR_SIZE + 2] = (byte) ((plainText.length >> 16) & 0xff);\n\t\taad[INITIALIZATION_VECTOR_SIZE + 3] = (byte) ((plainText.length >> 24) & 0xff);\n\n\t\tvar totalSize = HEADER_SIZE + INITIALIZATION_VECTOR_SIZE + EDATA_SIZE + plainText.length + AUTHENTICATION_TAG_SIZE;\n\t\tvar encryptedData = new byte[totalSize];\n\t\tvar offset = 0;\n\n\t\t// Header\n\t\tencryptedData[0] = (byte) 0xae;\n\t\tencryptedData[1] = (byte) 0xad;\n\t\tencryptedData[2] = (byte) format.getValue();\n\t\tencryptedData[3] = (byte) 0x1;\n\n\t\toffset += HEADER_SIZE;\n\n\t\t// Copy AAD data (initialization vector + length)\n\t\tSystem.arraycopy(aad, 0, encryptedData, offset, aad.length);\n\t\toffset += aad.length;\n\n\t\tbyte[] cipherText;\n\n\t\tif (encryptedData[2] == EncryptionFormat.CHACHA20_POLY1305.getValue())\n\t\t{\n\t\t\tcipherText = AEAD.encryptChaCha20Poly1305(key, initializationVector, plainText, aad);\n\t\t}\n\t\telse if (encryptedData[2] == EncryptionFormat.CHACHA20_SHA256.getValue())\n\t\t{\n\t\t\tcipherText = AEAD.encryptChaCha20Sha256(key, initializationVector, plainText, aad);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported encrypted data type: \" + encryptedData[2]);\n\t\t}\n\n\t\tSystem.arraycopy(cipherText, 0, encryptedData, offset, cipherText.length);\n\n\t\treturn encryptedData;\n\t}\n\n\tpublic static byte[] decryptAuthenticateData(SecretKey key, byte[] cipherText)\n\t{\n\t\tif (cipherText.length < HEADER_SIZE + INITIALIZATION_VECTOR_SIZE + EDATA_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Ciphertext is too short\");\n\t\t}\n\n\t\tvar buf = ByteBuffer.wrap(cipherText);\n\t\tvar magic1 = buf.get();\n\t\tvar magic2 = buf.get();\n\t\tvar format = buf.get();\n\t\tvar magic3 = buf.get();\n\n\t\tif (magic1 != (byte) 0xae && magic2 != (byte) 0xad && magic3 != (byte) 0x1)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Invalid ciphertext header\");\n\t\t}\n\t\tif (format != EncryptionFormat.CHACHA20_POLY1305.getValue() && format != EncryptionFormat.CHACHA20_SHA256.getValue())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported encrypted data type: \" + cipherText[2]);\n\t\t}\n\n\t\tvar initializationVector = new byte[INITIALIZATION_VECTOR_SIZE];\n\t\tbuf.get(initializationVector);\n\n\t\tvar aad = new byte[INITIALIZATION_VECTOR_SIZE + EDATA_SIZE];\n\t\tvar eDataArray = new byte[EDATA_SIZE];\n\t\tbuf.get(eDataArray);\n\t\tSystem.arraycopy(initializationVector, 0, aad, 0, INITIALIZATION_VECTOR_SIZE);\n\t\tSystem.arraycopy(eDataArray, 0, aad, INITIALIZATION_VECTOR_SIZE, EDATA_SIZE);\n\n\t\tvar eDataSize = Byte.toUnsignedInt(eDataArray[0]);\n\t\teDataSize += Byte.toUnsignedInt(eDataArray[1]) << 8;\n\t\teDataSize += Byte.toUnsignedInt(eDataArray[2]) << 16;\n\t\teDataSize += Byte.toUnsignedInt(eDataArray[3]) << 24;\n\n\t\tvar expectedSize = eDataSize + HEADER_SIZE + INITIALIZATION_VECTOR_SIZE + EDATA_SIZE + AUTHENTICATION_TAG_SIZE;\n\n\t\tif (expectedSize != cipherText.length)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Encrypted data size is wrong, expected: \" + expectedSize + \", got: \" + cipherText.length);\n\t\t}\n\n\t\tbyte[] decryptedText;\n\t\tvar encryptedText = new byte[eDataSize + AUTHENTICATION_TAG_SIZE];\n\t\tbuf.get(encryptedText);\n\n\t\tif (format == EncryptionFormat.CHACHA20_POLY1305.getValue())\n\t\t{\n\t\t\tdecryptedText = AEAD.decryptChaCha20Poly1305(key, initializationVector, encryptedText, aad);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tdecryptedText = AEAD.decryptChaCha20Sha256(key, initializationVector, encryptedText, aad);\n\t\t}\n\n\t\tvar decryptedData = new byte[eDataSize];\n\t\tSystem.arraycopy(decryptedText, 0, decryptedData, 0, eDataSize);\n\n\t\treturn decryptedData;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rscrypto/doc-files/format.puml",
    "content": "@startuml\nmap \"Packet\" as packet {\n\tHeader => 4 bytes\n\tInitialization vector => 12 bytes\n\tEncrypted data size => 4 bytes\n\tEncrypted data => variable\n\tAuthentication tag => 16 bytes\n}\n\nmap \"Header\" as header {\n\tChaCha20 Poly1305 => ae ad 01 01\n\tChaCha20 HMAC-SHA256 => ae ad 02 01\n}\n@enduml\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/RSCertificate.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.net.protocol.DomainNameSocketAddress;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.dto.profile.ProfileConstants;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.apache.commons.lang3.StringUtils;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.cert.CertificateParsingException;\nimport java.util.Base64;\nimport java.util.HashSet;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static io.xeres.app.crypto.rsid.RSIdArmor.*;\n\nclass RSCertificate extends RSId\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RSCertificate.class);\n\n\tpublic static final int VERSION_06 = 6;\n\n\tstatic final int PGP_KEY = 1;\n\tstatic final int EXTERNAL_IP_AND_PORT = 2;\n\tstatic final int INTERNAL_IP_AND_PORT = 3;\n\tstatic final int DNS = 4;\n\tstatic final int SSL_ID = 5;\n\tstatic final int NAME = 6;\n\tstatic final int CHECKSUM = 7;\n\tstatic final int HIDDEN_NODE = 8;\n\tstatic final int VERSION = 9;\n\tstatic final int EXTRA_LOCATOR = 10;\n\n\tprivate PGPPublicKey pgpPublicKey;\n\tprivate ProfileFingerprint pgpFingerprint;\n\n\tprivate String name;\n\tprivate LocationIdentifier locationIdentifier;\n\n\tprivate PeerAddress hiddenNodeAddress;\n\tprivate PeerAddress internalIp;\n\tprivate PeerAddress externalIp;\n\tprivate PeerAddress dnsName;\n\tprivate final Set<PeerAddress> locators = new HashSet<>();\n\n\tRSCertificate()\n\t{\n\t}\n\n\t@SuppressWarnings(\"DuplicatedCode\")\n\t@Override\n\tvoid parseInternal(String data) throws CertificateParsingException\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar certBytes = Base64.getDecoder().decode(cleanupInput(data.getBytes()));\n\t\t\tvar checksum = RSIdCrc.calculate24bitsCrc(certBytes, certBytes.length - 5); // ignore the checksum PTAG which is 5 bytes in total and at the end\n\t\t\tvar in = new ByteArrayInputStream(certBytes);\n\t\t\tvar version = 0;\n\t\t\tBoolean checksumPassed = null;\n\n\t\t\twhile (in.available() > 0)\n\t\t\t{\n\t\t\t\tvar pTag = in.read();\n\t\t\t\tvar size = getPacketSize(in);\n\t\t\t\tif (size == 0)\n\t\t\t\t{\n\t\t\t\t\tcontinue; // seen in the wild, just skip them\n\t\t\t\t}\n\t\t\t\tvar buf = new byte[size];\n\t\t\t\tif (in.readNBytes(buf, 0, size) != size)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"Packet \" + pTag + \" is shorter than its advertised size\");\n\t\t\t\t}\n\n\t\t\t\tswitch (pTag)\n\t\t\t\t{\n\t\t\t\t\tcase VERSION -> version = buf[0];\n\n\t\t\t\t\tcase PGP_KEY -> setPgpPublicKey(buf);\n\n\t\t\t\t\tcase NAME -> setLocationName(buf);\n\n\t\t\t\t\tcase SSL_ID -> setLocationIdentifier(new LocationIdentifier(buf));\n\n\t\t\t\t\tcase DNS -> setDnsName(buf);\n\n\t\t\t\t\tcase HIDDEN_NODE -> setHiddenNodeAddress(buf);\n\n\t\t\t\t\tcase INTERNAL_IP_AND_PORT -> setInternalIp(buf);\n\n\t\t\t\t\tcase EXTERNAL_IP_AND_PORT -> setExternalIp(buf);\n\n\t\t\t\t\tcase CHECKSUM ->\n\t\t\t\t\t{\n\t\t\t\t\t\tif (buf.length != 3)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tthrow new IllegalArgumentException(\"Checksum corrupted\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\tchecksumPassed = checksum == (Byte.toUnsignedInt(buf[2]) << 16 | Byte.toUnsignedInt(buf[1]) << 8 | Byte.toUnsignedInt(buf[0])); // little endian\n\t\t\t\t\t}\n\n\t\t\t\t\tcase EXTRA_LOCATOR ->\n\t\t\t\t\t{\n\t\t\t\t\t\t// XXX: insert the URLs (I probably need a RsUrl object...\n\t\t\t\t\t}\n\n\t\t\t\t\tdefault -> log.trace(\"Unhandled tag {}, ignoring.\", pTag);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (version == 0)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Missing certificate version\");\n\t\t\t}\n\t\t\telse if (version != RSCertificate.VERSION_06)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Wrong certificate version: \" + version);\n\t\t\t}\n\n\t\t\tif (checksumPassed == null)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Missing checksum packet\");\n\t\t\t}\n\t\t\telse if (!checksumPassed)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Wrong checksum\");\n\t\t\t}\n\t\t}\n\t\tcatch (IllegalArgumentException | IOException e)\n\t\t{\n\t\t\tthrow new CertificateParsingException(\"Parse error: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\t@Override\n\tvoid checkRequiredFields()\n\t{\n\t\tif (getLocationIdentifier() == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing location identifier\");\n\t\t}\n\t\tif (StringUtils.isBlank(getName()))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing or wrong name\");\n\t\t}\n\t\tif (getPgpPublicKey().isEmpty())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing PGP public key\");\n\t\t}\n\n\t\taddPortToDnsName();\n\t}\n\n\tprivate void addPortToDnsName()\n\t{\n\t\tif (dnsName != null && dnsName.isValid() && dnsName.getSocketAddress() instanceof DomainNameSocketAddress)\n\t\t{\n\t\t\t// Find another address for a port, then add it\n\t\t\tif (externalIp != null && externalIp.isValid())\n\t\t\t{\n\t\t\t\tdnsName = PeerAddress.fromHostname(dnsName.getAddress().orElseThrow(), ((InetSocketAddress) externalIp.getSocketAddress()).getPort());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tdnsName = PeerAddress.fromInvalid();\n\t\t\t}\n\t\t}\n\t}\n\n\tvoid setPgpPublicKey(byte[] data) throws CertificateParsingException\n\t{\n\t\ttry\n\t\t{\n\t\t\tsetPgpPublicKey(PGP.getPGPPublicKey(data));\n\t\t}\n\t\tcatch (InvalidKeyException e)\n\t\t{\n\t\t\tthrow new CertificateParsingException(\"Error in RSCertificate PGP public key: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\t/**\n\t * Same as setPgpPublicKey() but from a valid PGP key data.\n\t * This is done to avoid catching the exception.\n\t *\n\t * @param data the data\n\t */\n\tvoid setVerifiedPgpPublicKey(byte[] data)\n\t{\n\t\ttry\n\t\t{\n\t\t\tsetPgpPublicKey(PGP.getPGPPublicKey(data));\n\t\t}\n\t\tcatch (InvalidKeyException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tvoid setPgpPublicKey(PGPPublicKey pgpPublicKey)\n\t{\n\t\tthis.pgpPublicKey = pgpPublicKey;\n\t\tpgpFingerprint = new ProfileFingerprint(pgpPublicKey.getFingerprint());\n\t}\n\n\tprivate void setInternalIp(byte[] data)\n\t{\n\t\tinternalIp = PeerAddress.fromByteArray(data);\n\t}\n\n\tvoid setInternalIp(String ipAndPort)\n\t{\n\t\tinternalIp = PeerAddress.fromIpAndPort(ipAndPort);\n\t}\n\n\tprivate void setExternalIp(byte[] data)\n\t{\n\t\texternalIp = PeerAddress.fromByteArray(data);\n\t}\n\n\tvoid setExternalIp(String ipAndPort)\n\t{\n\t\texternalIp = PeerAddress.fromIpAndPort(ipAndPort);\n\t}\n\n\tprivate void setLocationName(byte[] name) throws CertificateParsingException\n\t{\n\t\tif (name.length > 255) // RS has no limit but let's enforce a sensible value\n\t\t{\n\t\t\tthrow new CertificateParsingException(\"Certificate name too long: \" + name.length);\n\t\t}\n\t\tthis.name = new String(name, StandardCharsets.UTF_8);\n\t}\n\n\tvoid setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\tprivate void setHiddenNodeAddress(byte[] hiddenNodeAddress)\n\t{\n\t\tif (hiddenNodeAddress != null && hiddenNodeAddress.length >= 11 && hiddenNodeAddress.length <= 255)\n\t\t{\n\t\t\tsetHiddenNodeAddress(new String(hiddenNodeAddress, StandardCharsets.US_ASCII));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.hiddenNodeAddress = PeerAddress.fromInvalid();\n\t\t}\n\t}\n\n\tprivate void setHiddenNodeAddress(String hiddenNodeAddress)\n\t{\n\t\tthis.hiddenNodeAddress = PeerAddress.fromHidden(hiddenNodeAddress);\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getInternalIp()\n\t{\n\t\tif (internalIp != null && internalIp.isValid())\n\t\t{\n\t\t\treturn Optional.of(internalIp);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getExternalIp()\n\t{\n\t\tif (externalIp != null && externalIp.isValid())\n\t\t{\n\t\t\treturn Optional.of(externalIp);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t@Override\n\tpublic ProfileFingerprint getPgpFingerprint()\n\t{\n\t\treturn pgpFingerprint;\n\t}\n\n\t@Override\n\tpublic Optional<PGPPublicKey> getPgpPublicKey()\n\t{\n\t\treturn Optional.ofNullable(pgpPublicKey);\n\t}\n\n\t@Override\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tvoid setName(byte[] name)\n\t{\n\t\tthis.name = StringUtils.substring(new String(name, StandardCharsets.UTF_8), 0, ProfileConstants.NAME_LENGTH_MAX);\n\t}\n\n\t@Override\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getDnsName()\n\t{\n\t\tif (dnsName != null && dnsName.isValid())\n\t\t{\n\t\t\treturn Optional.of(dnsName);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tprivate void setDnsName(byte[] dnsName)\n\t{\n\t\tsetDnsName(new String(dnsName, StandardCharsets.US_ASCII));\n\t}\n\n\tvoid setDnsName(String dnsName)\n\t{\n\t\tthis.dnsName = PeerAddress.fromHostname(dnsName);\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getHiddenNodeAddress()\n\t{\n\t\tif (hiddenNodeAddress != null && hiddenNodeAddress.isValid())\n\t\t{\n\t\t\treturn Optional.of(hiddenNodeAddress);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tvoid addLocator(String locator)\n\t{\n\t\tvar peerAddress = PeerAddress.fromUrl(locator);\n\n\t\tif (peerAddress.isValid())\n\t\t{\n\t\t\tlocators.add(peerAddress);\n\t\t}\n\t}\n\n\t@Override\n\tpublic Set<PeerAddress> getLocators()\n\t{\n\t\treturn locators;\n\t}\n\n\t@Override\n\tpublic String getArmored()\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\n\t\taddPacket(VERSION, new byte[]{RSCertificate.VERSION_06}, out);\n\t\taddPacket(PGP_KEY, getPgpPublicKeyData(pgpPublicKey), out);\n\n\t\tif (getHiddenNodeAddress().isPresent())\n\t\t{\n\t\t\taddPacket(HIDDEN_NODE, getHiddenNodeAddress().get().getAddressAsBytes().orElseThrow(), out);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tgetExternalIp().ifPresent(peerAddress -> addPacket(EXTERNAL_IP_AND_PORT, peerAddress.getAddressAsBytes().orElseThrow(), out));\n\t\t\tgetInternalIp().ifPresent(peerAddress -> addPacket(INTERNAL_IP_AND_PORT, peerAddress.getAddressAsBytes().orElseThrow(), out));\n\t\t\tgetDnsName().ifPresent(peerAddress -> addPacket(DNS, peerAddress.getAddressAsBytes().orElseThrow(), out));\n\t\t}\n\n\t\taddPacket(NAME, getName().getBytes(), out);\n\t\taddPacket(SSL_ID, getLocationIdentifier().getBytes(), out);\n\n\t\tgetLocators().forEach(peerAddress -> addPacket(EXTRA_LOCATOR, peerAddress.getAddressAsBytes().orElseThrow(), out));\n\n\t\taddCrcPacket(CHECKSUM, out);\n\n\t\treturn wrapWithBase64(out.toByteArray(), RSIdArmor.WrapMode.SLICED);\n\t}\n\n\tprivate static byte[] getPgpPublicKeyData(PGPPublicKey pgpPublicKey)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn pgpPublicKey.getEncoded();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/RSId.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.rsid.Type;\nimport org.apache.commons.lang3.StringUtils;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.lang.reflect.InvocationTargetException;\nimport java.security.cert.CertificateParsingException;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static io.xeres.common.rsid.Type.*;\n\n/**\n * This abstract class represents an RS ID, which is a string that allows to exchange a profile identity\n * with another user.\n */\npublic abstract class RSId\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RSId.class);\n\n\tprivate static final Map<Class<? extends RSId>, Type> engines = LinkedHashMap.newLinkedHashMap(2);\n\n\tstatic\n\t{\n\t\tengines.put(ShortInvite.class, SHORT_INVITE);\n\t\tengines.put(RSCertificate.class, CERTIFICATE);\n\t}\n\n\t/**\n\t * Parses an ID.\n\t *\n\t * @param data the ID encoded in a string\n\t * @param type restrict the type to parse or use ANY\n\t * @return an RSId\n\t */\n\tpublic static Optional<RSId> parse(String data, Type type)\n\t{\n\t\tif (StringUtils.isBlank(data))\n\t\t{\n\t\t\treturn Optional.empty();\n\t\t}\n\n\t\tString error = null;\n\n\t\tfor (var entry : engines.entrySet())\n\t\t{\n\t\t\tvar engineClass = entry.getKey();\n\t\t\tvar engineType = entry.getValue();\n\n\t\t\tif (type != ANY && type != engineType)\n\t\t\t{\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar rsId = engineClass.getDeclaredConstructor().newInstance();\n\t\t\t\trsId.parseInternal(data);\n\t\t\t\trsId.checkRequiredFieldsAndThrow();\n\t\t\t\treturn Optional.of(rsId);\n\t\t\t}\n\t\t\tcatch (NoSuchMethodException _)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(engineClass.getSimpleName() + \" requires an empty constructor\");\n\t\t\t}\n\t\t\tcatch (InvocationTargetException | InstantiationException | IllegalAccessException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t\tcatch (CertificateParsingException e)\n\t\t\t{\n\t\t\t\t// Parsing failed, try the next one\n\t\t\t\terror = e.getMessage();\n\t\t\t}\n\t\t}\n\t\tlog.debug(\"RSId parsing error: {}\", error);\n\t\treturn Optional.empty();\n\t}\n\n\tabstract void parseInternal(String data) throws CertificateParsingException;\n\n\tabstract void checkRequiredFields();\n\n\t/**\n\t * Gets the internal IP (IP used on the LAN).\n\t *\n\t * @return the internal IP (for example 192.168.1.10)\n\t */\n\tpublic abstract Optional<PeerAddress> getInternalIp();\n\n\t/**\n\t * Gets the external IP (IP used on the Internet).\n\t *\n\t * @return the external IP (for example 85.12.43.18)\n\t */\n\tpublic abstract Optional<PeerAddress> getExternalIp();\n\n\t/**\n\t * Gets the PGP fingerprint. Should always be available.\n\t *\n\t * @return the PGP fingerprint\n\t */\n\tpublic abstract ProfileFingerprint getPgpFingerprint();\n\n\t/**\n\t * Gets the PGP public key (optional).\n\t *\n\t * @return the PGP public key\n\t */\n\tpublic abstract Optional<PGPPublicKey> getPgpPublicKey();\n\n\t/**\n\t * Gets the profile name (usually the name or nickname of the user).\n\t *\n\t * @return the profile name\n\t */\n\tpublic abstract String getName();\n\n\t/**\n\t * Gets the location identifier (node identifier).\n\t *\n\t * @return the location identifier\n\t */\n\tpublic abstract LocationIdentifier getLocationIdentifier();\n\n\t/**\n\t * Gets the DNS name.\n\t *\n\t * @return the DNS name\n\t */\n\tpublic abstract Optional<PeerAddress> getDnsName();\n\n\t/**\n\t * Gets the hidden node address, if this is a hidden node.\n\t *\n\t * @return the hidden node address\n\t */\n\tpublic abstract Optional<PeerAddress> getHiddenNodeAddress();\n\n\t/**\n\t * Gets a set of addresses where the node is available.\n\t *\n\t * @return a set of addresses\n\t */\n\tpublic abstract Set<PeerAddress> getLocators();\n\n\t/**\n\t * Gets an armored version of the certificate or short invite. It's encoded using base64 and can be\n\t * used in emails, forums, etc...\n\t *\n\t * @return an ASCII armored version of it\n\t */\n\tpublic abstract String getArmored();\n\n\t/**\n\t * Gets the PGP identifier, which is the last long of the PGP fingerprint\n\t *\n\t * @return the PGP identifier\n\t */\n\tpublic Long getPgpIdentifier()\n\t{\n\t\tif (getPgpFingerprint() == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn PGP.getPGPIdentifierFromFingerprint(getPgpFingerprint().getBytes());\n\t}\n\n\tprotected static byte[] cleanupInput(byte[] data)\n\t{\n\t\ttry (var out = new ByteArrayOutputStream())\n\t\t{\n\t\t\tfor (var b : data)\n\t\t\t{\n\t\t\t\tif (b == ' ' || b == '\\n' || b == '\\t' || b == '\\r')\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tout.write(b);\n\t\t\t}\n\t\t\treturn out.toByteArray();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(e);\n\t\t}\n\t}\n\n\tprotected static int getPacketSize(InputStream in) throws IOException\n\t{\n\t\tvar octet1 = in.read();\n\n\t\tif (octet1 < 192) // size is coded in one byte\n\t\t{\n\t\t\treturn octet1;\n\t\t}\n\t\telse if (octet1 < 224) // size is coded in 2 bytes\n\t\t{\n\t\t\tvar octet2 = in.read();\n\n\t\t\treturn ((octet1 - 192) << 8) + octet2 + 192;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported packet data size\");\n\t\t}\n\t}\n\n\tprivate void checkRequiredFieldsAndThrow() throws CertificateParsingException\n\t{\n\t\ttry\n\t\t{\n\t\t\tcheckRequiredFields();\n\t\t}\n\t\tcatch (IllegalArgumentException e)\n\t\t{\n\t\t\tthrow new CertificateParsingException(\"Required field error: \" + e.getMessage(), e);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/RSIdArmor.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\n\nfinal class RSIdArmor\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RSIdArmor.class);\n\n\tenum WrapMode\n\t{\n\t\tCONTINUOUS,\n\t\tSLICED\n\t}\n\n\tprivate RSIdArmor()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic void addPacket(int pTag, byte[] data, ByteArrayOutputStream out)\n\t{\n\t\tif (data != null)\n\t\t{\n\t\t\t// This is like PGP packets, see https://tools.ietf.org/html/rfc4880\n\t\t\tout.write(pTag);\n\t\t\tif (data.length < 192) // size is coded in one byte\n\t\t\t{\n\t\t\t\t// one byte\n\t\t\t\tout.write(data.length);\n\t\t\t}\n\t\t\telse if (data.length < 8384) // size is coded in 2 bytes\n\t\t\t{\n\t\t\t\tvar octet2 = (data.length - 192) & 0xff;\n\t\t\t\tout.write(((data.length - 192 - octet2) >> 8) + 192);\n\t\t\t\tout.write(octet2);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// We don't support more as it makes little sense to have an oversized certificate\n\t\t\t\tthrow new IllegalArgumentException(\"Packet data size too big: \" + data.length);\n\t\t\t}\n\t\t\tout.writeBytes(data);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.warn(\"Trying to write certificate tag {} with empty data. Skipping...\", pTag);\n\t\t}\n\t}\n\n\tstatic void addCrcPacket(int pTag, ByteArrayOutputStream out)\n\t{\n\t\tvar data = out.toByteArray();\n\n\t\tvar crc = RSIdCrc.calculate24bitsCrc(data, data.length);\n\n\t\t// Perform byte swapping\n\t\tvar le = new byte[3];\n\t\tle[0] = (byte) (crc & 0xff);\n\t\tle[1] = (byte) ((crc >> 8) & 0xff);\n\t\tle[2] = (byte) ((crc >> 16) & 0xff);\n\n\t\taddPacket(pTag, le, out);\n\t}\n\n\tstatic String wrapWithBase64(byte[] data, WrapMode wrapMode)\n\t{\n\t\tvar base64 = Base64.getEncoder().encode(data);\n\n\t\ttry (var out = new ByteArrayOutputStream())\n\t\t{\n\t\t\tfor (var i = 0; i < base64.length; i++)\n\t\t\t{\n\t\t\t\tout.write(base64[i]);\n\n\t\t\t\tif (wrapMode == WrapMode.SLICED && i % 64 == 64 - 1)\n\t\t\t\t{\n\t\t\t\t\tout.write('\\n');\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn out.toString(StandardCharsets.US_ASCII);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(e);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/RSIdBuilder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.rsid.Type;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\n\npublic class RSIdBuilder\n{\n\tprivate final Type type;\n\tprivate byte[] name;\n\tprivate LocationIdentifier locationIdentifier;\n\tprivate Profile profile;\n\tprivate byte[] pgpFingerprint;\n\tprivate final List<String> locators = new ArrayList<>();\n\tprivate String externalLocator;\n\tprivate String lanLocator;\n\tprivate String dnsLocator;\n\n\tpublic RSIdBuilder(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tpublic RSIdBuilder setName(byte[] name)\n\t{\n\t\tthis.name = name;\n\t\treturn this;\n\t}\n\n\tpublic RSIdBuilder setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t\treturn this;\n\t}\n\n\tpublic RSIdBuilder setProfile(Profile profile)\n\t{\n\t\tthis.profile = profile;\n\t\treturn this;\n\t}\n\n\tpublic RSIdBuilder setPgpFingerprint(byte[] pgpFingerprint)\n\t{\n\t\tthis.pgpFingerprint = pgpFingerprint;\n\t\treturn this;\n\t}\n\n\tpublic RSIdBuilder addLocator(Connection connection)\n\t{\n\t\tif (externalLocator == null && connection.isExternal())\n\t\t{\n\t\t\texternalLocator = connection.getAddress();\n\t\t}\n\t\telse if (lanLocator == null && !connection.isExternal())\n\t\t{\n\t\t\tlanLocator = connection.getAddress();\n\t\t}\n\t\telse if (dnsLocator == null && connection.getType() == PeerAddress.Type.HOSTNAME)\n\t\t{\n\t\t\tdnsLocator = connection.getAddress();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlocators.add(connection.getType().scheme() + connection.getAddress());\n\t\t}\n\t\treturn this;\n\t}\n\n\tpublic RSId build()\n\t{\n\t\tvar rsId = switch (type)\n\t\t\t\t{\n\t\t\t\t\tcase SHORT_INVITE, ANY -> {\n\t\t\t\t\t\tvar si = new ShortInvite();\n\n\t\t\t\t\t\tObjects.requireNonNull(name);\n\t\t\t\t\t\tObjects.requireNonNull(locationIdentifier);\n\t\t\t\t\t\tObjects.requireNonNull(pgpFingerprint);\n\n\t\t\t\t\t\tsi.setName(name);\n\t\t\t\t\t\tsi.setLocationIdentifier(locationIdentifier);\n\t\t\t\t\t\tsi.setPgpFingerprint(pgpFingerprint);\n\n\t\t\t\t\t\tif (externalLocator != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tsi.setExt4Locator(externalLocator);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (lanLocator != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tsi.setLoc4Locator(lanLocator);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (dnsLocator != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tsi.setDnsName(dnsLocator);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlocators.forEach(si::addLocator);\n\n\t\t\t\t\t\tyield si;\n\t\t\t\t\t}\n\t\t\t\t\tcase CERTIFICATE -> {\n\t\t\t\t\t\tvar cert = new RSCertificate();\n\n\t\t\t\t\t\tObjects.requireNonNull(name);\n\t\t\t\t\t\tObjects.requireNonNull(locationIdentifier);\n\t\t\t\t\t\tObjects.requireNonNull(profile);\n\n\t\t\t\t\t\tcert.setName(name);\n\t\t\t\t\t\tcert.setLocationIdentifier(locationIdentifier);\n\t\t\t\t\t\tcert.setVerifiedPgpPublicKey(profile.getPgpPublicKeyData());\n\n\t\t\t\t\t\tif (externalLocator != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcert.setExternalIp(externalLocator);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (lanLocator != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcert.setInternalIp(lanLocator);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (dnsLocator != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcert.setDnsName(dnsLocator);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlocators.forEach(cert::addLocator);\n\n\t\t\t\t\t\tyield cert;\n\t\t\t\t\t}\n\t\t\t\t};\n\t\trsId.checkRequiredFields();\n\t\treturn rsId;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/RSIdCrc.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nfinal class RSIdCrc\n{\n\tprivate RSIdCrc()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int calculate24bitsCrc(byte[] data, int length)\n\t{\n\t\tvar crc = 0xb704ce;\n\n\t\tfor (var i = 0; i < length; i++)\n\t\t{\n\t\t\tcrc ^= data[i] << 16;\n\t\t\tfor (var j = 0; j < 8; j++)\n\t\t\t{\n\t\t\t\tcrc <<= 1;\n\t\t\t\tif ((crc & 0x1000000) != 0)\n\t\t\t\t{\n\t\t\t\t\tcrc ^= 0x1864cfb;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn crc & 0xffffff;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/RSSerialVersion.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport java.math.BigInteger;\n\npublic enum RSSerialVersion\n{\n\tV06_0000(\"60000\", \"Retroshare 0.6.4 or earlier\"), // RS 0.6.4 and earlier, before November 2017 (note that the version which is in the cert's serial number can be random)\n\tV06_0001(\"60001\", \"Retroshare 0.6.5\"), // RS 0.6.5 after November 2017\n\tV07_0001(\"70001\", \"Retroshare 0.6.6\"); // RS 0.6.6\n\n\tprivate final String versionString;\n\tprivate final String description;\n\n\tRSSerialVersion(String versionString, String description)\n\t{\n\t\tthis.versionString = versionString;\n\t\tthis.description = description;\n\t}\n\n\tpublic BigInteger serialNumber()\n\t{\n\t\treturn new BigInteger(versionString, 16);\n\t}\n\n\tpublic String versionString()\n\t{\n\t\treturn versionString;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn description + \" (\" + versionString + \")\";\n\t}\n\n\tpublic static RSSerialVersion getFromSerialNumber(BigInteger serialNumber)\n\t{\n\t\tfor (var value : values())\n\t\t{\n\t\t\tif (value.serialNumber().equals(serialNumber))\n\t\t\t{\n\t\t\t\treturn value;\n\t\t\t}\n\t\t}\n\t\treturn V06_0000; // old versions used a random serial number\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/rsid/ShortInvite.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.dto.profile.ProfileConstants;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.apache.commons.lang3.StringUtils;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.cert.CertificateParsingException;\nimport java.util.*;\n\nimport static io.xeres.app.crypto.rsid.RSIdArmor.*;\n\nclass ShortInvite extends RSId\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ShortInvite.class);\n\n\tstatic final int SSL_ID = 0x0;\n\tstatic final int NAME = 0x1;\n\tstatic final int LOCATOR = 0x2;\n\tstatic final int PGP_FINGERPRINT = 0x3;\n\tstatic final int CHECKSUM = 0x4;\n\tstatic final int HIDDEN_LOCATOR = 0x90;\n\tstatic final int DNS_LOCATOR = 0x91;\n\tstatic final int EXT4_LOCATOR = 0x92;\n\tstatic final int LOC4_LOCATOR = 0x93;\n\n\tprivate String name;\n\tprivate LocationIdentifier locationIdentifier;\n\n\tprivate ProfileFingerprint pgpFingerprint;\n\tprivate PeerAddress hiddenLocator;\n\tprivate PeerAddress ext4Locator;\n\tprivate PeerAddress loc4Locator;\n\tprivate PeerAddress hostnameLocator;\n\tprivate final Set<PeerAddress> locators = new HashSet<>();\n\n\tShortInvite()\n\t{\n\t}\n\n\t@SuppressWarnings(\"DuplicatedCode\")\n\t@Override\n\tvoid parseInternal(String data) throws CertificateParsingException\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar shortInviteBytes = Base64.getDecoder().decode(cleanupInput(data.getBytes()));\n\t\t\tvar checksum = RSIdCrc.calculate24bitsCrc(shortInviteBytes, shortInviteBytes.length - 5); // ignore the checksum PTAG which is 5 bytes in total and at the end\n\t\t\tvar in = new ByteArrayInputStream(shortInviteBytes);\n\t\t\tBoolean checksumPassed = null;\n\n\t\t\twhile (in.available() > 0)\n\t\t\t{\n\t\t\t\tvar pTag = in.read();\n\t\t\t\tvar size = getPacketSize(in);\n\t\t\t\tif (size == 0)\n\t\t\t\t{\n\t\t\t\t\tcontinue; // not seen in the wild yet but just skip them in any case\n\t\t\t\t}\n\t\t\t\tvar buf = new byte[size];\n\t\t\t\tif (in.readNBytes(buf, 0, size) != size)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"Packet \" + pTag + \" is shorter than its advertised size\");\n\t\t\t\t}\n\n\t\t\t\tswitch (pTag)\n\t\t\t\t{\n\t\t\t\t\tcase PGP_FINGERPRINT -> setPgpFingerprint(buf);\n\t\t\t\t\tcase NAME -> setName(buf);\n\t\t\t\t\tcase SSL_ID -> setLocationIdentifier(new LocationIdentifier(buf));\n\t\t\t\t\tcase DNS_LOCATOR -> setDnsName(buf);\n\t\t\t\t\tcase HIDDEN_LOCATOR -> setHiddenNodeAddress(buf);\n\t\t\t\t\tcase EXT4_LOCATOR -> setExt4Locator(buf);\n\t\t\t\t\tcase LOC4_LOCATOR -> setLoc4Locator(buf);\n\t\t\t\t\tcase LOCATOR -> addLocator(new String(buf));\n\t\t\t\t\tcase CHECKSUM -> {\n\t\t\t\t\t\tif (buf.length != 3)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tthrow new IllegalArgumentException(\"Checksum corrupted\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\tchecksumPassed = checksum == (Byte.toUnsignedInt(buf[2]) << 16 | Byte.toUnsignedInt(buf[1]) << 8 | Byte.toUnsignedInt(buf[0])); // little endian\n\t\t\t\t\t}\n\t\t\t\t\tdefault -> log.trace(\"Unhandled tag {}, ignoring.\", pTag);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (checksumPassed == null)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Missing checksum packet\");\n\t\t\t}\n\t\t\telse if (!checksumPassed)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Wrong checksum\");\n\t\t\t}\n\t\t}\n\t\tcatch (IllegalArgumentException | IOException e)\n\t\t{\n\t\t\tthrow new CertificateParsingException(\"Parse error: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\t@Override\n\tvoid checkRequiredFields()\n\t{\n\t\tif (getLocationIdentifier() == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing location id\");\n\t\t}\n\t\tif (getName() == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing name\");\n\t\t}\n\t\tif (getPgpFingerprint() == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing PGP fingerprint\");\n\t\t}\n\t}\n\n\tvoid setExt4Locator(byte[] data)\n\t{\n\t\text4Locator = PeerAddress.fromByteArray(swapBytes(data));\n\t}\n\n\tvoid setExt4Locator(String ipAndPort)\n\t{\n\t\text4Locator = PeerAddress.fromIpAndPort(ipAndPort);\n\t}\n\n\tvoid setLoc4Locator(byte[] data)\n\t{\n\t\tloc4Locator = PeerAddress.fromByteArray(swapBytes(data));\n\t}\n\n\tvoid setLoc4Locator(String ipAndPort)\n\t{\n\t\tloc4Locator = PeerAddress.fromIpAndPort(ipAndPort);\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getInternalIp()\n\t{\n\t\tif (loc4Locator != null && loc4Locator.isValid())\n\t\t{\n\t\t\treturn Optional.of(loc4Locator);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getExternalIp()\n\t{\n\t\tif (ext4Locator != null && ext4Locator.isValid())\n\t\t{\n\t\t\treturn Optional.of(ext4Locator);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tvoid setPgpFingerprint(byte[] pgpFingerprint)\n\t{\n\t\tthis.pgpFingerprint = new ProfileFingerprint(pgpFingerprint);\n\t}\n\n\t@Override\n\tpublic ProfileFingerprint getPgpFingerprint()\n\t{\n\t\treturn pgpFingerprint;\n\t}\n\n\t@Override\n\tpublic Optional<PGPPublicKey> getPgpPublicKey()\n\t{\n\t\treturn Optional.empty();\n\t}\n\n\t@Override\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tvoid setName(byte[] name)\n\t{\n\t\tthis.name = StringUtils.substring(new String(name, StandardCharsets.UTF_8), 0, ProfileConstants.NAME_LENGTH_MAX);\n\t}\n\n\t@Override\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tvoid setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getDnsName()\n\t{\n\t\treturn Optional.ofNullable(hostnameLocator);\n\t}\n\n\tvoid setDnsName(String dnsName)\n\t{\n\t\thostnameLocator = PeerAddress.fromHostnameAndPort(dnsName);\n\t}\n\n\tprivate void setDnsName(byte[] portAndDns)\n\t{\n\t\tif (portAndDns == null || portAndDns.length <= 3 || portAndDns.length > 255)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"DNS name format is wrong\");\n\t\t}\n\n\t\tvar port = Byte.toUnsignedInt(portAndDns[0]) << 8 | Byte.toUnsignedInt(portAndDns[1]);\n\t\tvar hostname = new String(Arrays.copyOfRange(portAndDns, 2, portAndDns.length), StandardCharsets.US_ASCII);\n\t\thostnameLocator = PeerAddress.fromHostname(hostname, port);\n\t}\n\n\t@Override\n\tpublic Optional<PeerAddress> getHiddenNodeAddress()\n\t{\n\t\tif (hiddenLocator != null && hiddenLocator.isValid())\n\t\t{\n\t\t\treturn Optional.of(hiddenLocator);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tprivate void setHiddenNodeAddress(String hiddenNodeAddress)\n\t{\n\t\thiddenLocator = PeerAddress.fromHidden(hiddenNodeAddress);\n\t}\n\n\tprivate void setHiddenNodeAddress(byte[] hiddenNodeAddress)\n\t{\n\t\tif (hiddenNodeAddress != null && hiddenNodeAddress.length >= 11 && hiddenNodeAddress.length <= 255)\n\t\t{\n\t\t\tvar port = Byte.toUnsignedInt(hiddenNodeAddress[4]) << 8 | Byte.toUnsignedInt(hiddenNodeAddress[5]);\n\t\t\tsetHiddenNodeAddress(new String(Arrays.copyOfRange(hiddenNodeAddress, 6, hiddenNodeAddress.length), StandardCharsets.US_ASCII) + \":\" + port);\n\t\t}\n\t\telse\n\t\t{\n\t\t\thiddenLocator = PeerAddress.fromInvalid();\n\t\t}\n\t}\n\n\tvoid addLocator(String locator)\n\t{\n\t\tvar peerAddress = PeerAddress.fromUrl(locator);\n\n\t\tif (peerAddress.isValid())\n\t\t{\n\t\t\tlocators.add(peerAddress);\n\t\t}\n\t}\n\n\t@Override\n\tpublic Set<PeerAddress> getLocators()\n\t{\n\t\treturn locators;\n\t}\n\n\t@Override\n\tpublic String getArmored()\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\n\t\taddPacket(SSL_ID, getLocationIdentifier().getBytes(), out);\n\t\taddPacket(NAME, getName().getBytes(), out);\n\t\taddPacket(PGP_FINGERPRINT, getPgpFingerprint().getBytes(), out);\n\t\tif (getHiddenNodeAddress().isPresent())\n\t\t{\n\t\t\taddPacket(HIDDEN_LOCATOR, getHiddenNodeAddress().get().getAddressAsBytes().orElseThrow(), out);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tgetDnsName().ifPresent(peerAddress -> addPacket(DNS_LOCATOR, swapDnsBytes(peerAddress.getAddressAsBytes().orElseThrow()), out));\n\t\t\tgetExternalIp().ifPresent(peerAddress -> addPacket(EXT4_LOCATOR, swapBytes(peerAddress.getAddressAsBytes().orElseThrow()), out));\n\t\t\tgetInternalIp().ifPresent(peerAddress -> addPacket(LOC4_LOCATOR, swapBytes(peerAddress.getAddressAsBytes().orElseThrow()), out));\n\t\t\t// Use one locator. Ideally, the first one should be the most recent address\n\t\t\tgetLocators().stream()\n\t\t\t\t\t.findFirst()\n\t\t\t\t\t.ifPresent(peerAddress -> addPacket(LOCATOR, peerAddress.getUrl().getBytes(StandardCharsets.US_ASCII), out));\n\t\t}\n\t\taddCrcPacket(CHECKSUM, out);\n\n\t\treturn wrapWithBase64(out.toByteArray(), RSIdArmor.WrapMode.CONTINUOUS);\n\t}\n\n\t/**\n\t * Retroshare puts IP addresses in big-endian in certificates, but when it comes\n\t * to short invites, a mistake was made and, while the port is in big-endian, the\n\t * IP address is not. Since the mistake is done on output and input, it works fine\n\t * within Retroshare so a workaround has to be implemented here.\n\t *\n\t * @param data the IP address + port\n\t * @return the IP address in swapped endian + port left alone\n\t */\n\tstatic byte[] swapBytes(byte[] data)\n\t{\n\t\tif (data == null || data.length != 6)\n\t\t{\n\t\t\treturn data; // don't touch anything, input is bad\n\t\t}\n\t\tvar bytes = new byte[6];\n\t\tbytes[0] = data[3];\n\t\tbytes[1] = data[2];\n\t\tbytes[2] = data[1];\n\t\tbytes[3] = data[0];\n\t\tbytes[4] = data[4];\n\t\tbytes[5] = data[5];\n\n\t\treturn bytes;\n\t}\n\n\tprivate static byte[] swapDnsBytes(byte[] data)\n\t{\n\t\tif (data == null || data.length < 4)\n\t\t{\n\t\t\treturn data; // don't touch anything, input is bad\n\t\t}\n\t\tvar bytes = new byte[data.length];\n\t\tSystem.arraycopy(data, 0, bytes, 2, data.length - 2);\n\t\tbytes[0] = data[data.length - 2];\n\t\tbytes[1] = data[data.length - 1];\n\n\t\treturn bytes;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/crypto/x509/X509.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.x509;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.crypto.hash.sha256.Sha256MessageDigest;\nimport io.xeres.app.crypto.pgp.PGPSigner;\nimport io.xeres.app.crypto.rsid.RSSerialVersion;\nimport io.xeres.common.id.LocationIdentifier;\nimport org.bouncycastle.asn1.x500.X500Name;\nimport org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;\nimport org.bouncycastle.cert.X509v1CertificateBuilder;\nimport org.bouncycastle.openpgp.PGPSecretKey;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.math.BigInteger;\nimport java.security.PublicKey;\nimport java.security.cert.CertificateException;\nimport java.security.cert.CertificateFactory;\nimport java.security.cert.X509Certificate;\nimport java.util.Date;\nimport java.util.Optional;\n\n/**\n * Implements all X509 certificate functions. Used to create an SSL certificate for the location.\n */\npublic final class X509\n{\n\tprivate static final String CERTIFICATE_TYPE = \"X.509\";\n\n\tprivate X509()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Generates a certificate.\n\t *\n\t * @param pgpSecretKey a PGP secret key\n\t * @param rsaPublicKey an RSA public key\n\t * @param issuer       the issuer\n\t * @param subject      the subject\n\t * @param dateOfIssue  the date of certificate validity\n\t * @param dateOfExpiry the date of certificate expiration\n\t * @param serial       the serial number\n\t * @return a {@link X509Certificate}\n\t * @throws IOException          if there's an I/O error\n\t * @throws CertificateException if there's a certificate error\n\t */\n\tpublic static X509Certificate generateCertificate(PGPSecretKey pgpSecretKey, PublicKey rsaPublicKey, String issuer, String subject, Date dateOfIssue, Date dateOfExpiry, BigInteger serial) throws IOException, CertificateException\n\t{\n\t\tvar certificateBuilder = new X509v1CertificateBuilder(\n\t\t\t\tnew X500Name(issuer),\n\t\t\t\tserial,\n\t\t\t\tdateOfIssue,\n\t\t\t\tdateOfExpiry,\n\t\t\t\tnew X500Name(subject),\n\t\t\t\tSubjectPublicKeyInfo.getInstance(rsaPublicKey.getEncoded())\n\t\t);\n\n\t\tvar pgpSigner = new PGPSigner(pgpSecretKey);\n\t\tvar certificateBytes = certificateBuilder.build(pgpSigner).getEncoded();\n\n\t\treturn (X509Certificate) CertificateFactory.getInstance(CERTIFICATE_TYPE).generateCertificate(new ByteArrayInputStream(certificateBytes));\n\t}\n\n\t/**\n\t * Gets the certificate from its encoded form.\n\t *\n\t * @param data a byte array with the encoded certificate\n\t * @return a X509 certificate\n\t * @throws CertificateException if there's a parse error\n\t */\n\tpublic static X509Certificate getCertificate(byte[] data) throws CertificateException\n\t{\n\t\treturn (X509Certificate) CertificateFactory.getInstance(CERTIFICATE_TYPE).generateCertificate(new ByteArrayInputStream(data));\n\t}\n\n\t/**\n\t * Gets the SSL ID of the certificate.\n\t *\n\t * @param certificate the X509 certificate\n\t * @return the ID that can be used as SSL ID\n\t */\n\tpublic static LocationIdentifier getLocationIdentifier(X509Certificate certificate) throws CertificateException\n\t{\n\t\tvar serialNumber = Optional.ofNullable(certificate.getSerialNumber()).orElseThrow(() -> new CertificateException(\"Missing serial number\"));\n\n\t\tvar out = new byte[LocationIdentifier.LENGTH];\n\n\t\t// There are several certificate versions\n\t\tif (serialNumber.equals(RSSerialVersion.V07_0001.serialNumber()))\n\t\t{\n\t\t\t// RS 0.6.6. ID is SHA-256 of signature (16 first bytes)\n\t\t\tvar md = new Sha256MessageDigest();\n\t\t\tmd.update(certificate.getSignature());\n\t\t\tSystem.arraycopy(md.getBytes(), 0, out, 0, out.length);\n\t\t}\n\t\telse if (serialNumber.equals(RSSerialVersion.V06_0001.serialNumber()))\n\t\t{\n\t\t\t// RS 0.6.5 after November 2017, ID is SHA-1 of signature (16 first bytes)\n\t\t\tvar md = new Sha1MessageDigest();\n\t\t\tmd.update(certificate.getSignature());\n\t\t\tSystem.arraycopy(md.getBytes(), 0, out, 0, out.length);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// The serial number here is either \"60000\" or a totally random string.\n\t\t\t// RS < November 2017. ID is the last 16 bytes of the signature.\n\t\t\tSystem.arraycopy(certificate.getSignature(), certificate.getSignature().length - out.length, out, 0, out.length);\n\t\t}\n\t\treturn new LocationIdentifier(out);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/DatabaseSession.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database;\n\n/**\n * Allows using transactions from outside spring controllers, while still allowing the controller\n * to call such methods directly. For example:\n * {@snippet :\n * \ttry (var session = new DatabaseSession(databaseSessionManager))\n * \t{\n *     doStuff();\n * \t}\n *}\n */\npublic class DatabaseSession implements AutoCloseable\n{\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final boolean isBound;\n\n\tpublic DatabaseSession(DatabaseSessionManager databaseSessionManager)\n\t{\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tisBound = databaseSessionManager.bindSession();\n\t}\n\n\t@Override\n\tpublic void close()\n\t{\n\t\tif (isBound)\n\t\t{\n\t\t\tdatabaseSessionManager.unbindSession();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/DatabaseSessionManager.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database;\n\nimport jakarta.persistence.EntityManagerFactory;\nimport jakarta.persistence.PersistenceUnit;\nimport org.springframework.orm.jpa.EntityManagerFactoryUtils;\nimport org.springframework.orm.jpa.EntityManagerHolder;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.support.TransactionSynchronizationManager;\n\n/**\n * Allows using @Transaction from outside Spring Boot threads. Prefer using {@link DatabaseSession} which implements\n * an AutoCloseable interface.\n */\n@Component\npublic class DatabaseSessionManager\n{\n\t@PersistenceUnit\n\tprivate EntityManagerFactory entityManagerFactory;\n\n\tpublic boolean bindSession()\n\t{\n\t\tif (!TransactionSynchronizationManager.hasResource(entityManagerFactory))\n\t\t{\n\t\t\tvar entityManager = entityManagerFactory.createEntityManager();\n\t\t\tTransactionSynchronizationManager.bindResource(entityManagerFactory, new EntityManagerHolder(entityManager));\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tpublic void unbindSession()\n\t{\n\t\tvar emHolder = (EntityManagerHolder) TransactionSynchronizationManager.unbindResource(entityManagerFactory);\n\t\tEntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/AvailabilityConverter.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.common.location.Availability;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class AvailabilityConverter extends EnumConverter<Availability>\n{\n\t@Override\n\tClass<Availability> getEnumClass()\n\t{\n\t\treturn Availability.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/EnumConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport jakarta.persistence.AttributeConverter;\n\n/**\n * This class is needed because Hibernate uses the ordinal value of enums to save them in the database and\n * some smartass changed enums in H2 to start from 1 instead of 0. Of course this breaks everything.\n * <p>\n * Don't forget to annotate your subclass with @Converter(autoApply = true)!\n */\n@SuppressWarnings(\"ConverterNotAnnotatedInspection\")\npublic abstract class EnumConverter<E extends Enum<E>> implements AttributeConverter<E, Integer>\n{\n\tabstract Class<E> getEnumClass();\n\n\t@Override\n\tpublic Integer convertToDatabaseColumn(E attribute)\n\t{\n\t\tif (attribute == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn attribute.ordinal() + 1;\n\t}\n\n\t@Override\n\tpublic E convertToEntityAttribute(Integer value)\n\t{\n\t\tif (value == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar e = getEnumClass();\n\n\t\tfor (var enumConstant : e.getEnumConstants())\n\t\t{\n\t\t\tif (value == enumConstant.ordinal() + 1)\n\t\t\t{\n\t\t\t\treturn enumConstant;\n\t\t\t}\n\t\t}\n\t\tthrow new IllegalArgumentException(\"Ordinal value \" + value + \" doesn't exist for enum \" + e);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/EnumSetConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport jakarta.persistence.AttributeConverter;\n\nimport java.util.EnumSet;\nimport java.util.Set;\n\n/**\n * This class is needed because Hibernate uses the ordinal value of enums to save them in the database and\n * some smartass changed enums in H2 to start from 1 instead of 0. Of course this breaks everything.\n * <p>\n * Don't forget to annotate your subclass with @Converter(autoApply = true)!\n */\n@SuppressWarnings(\"ConverterNotAnnotatedInspection\")\npublic abstract class EnumSetConverter<E extends Enum<E>> implements AttributeConverter<Set<E>, Integer>\n{\n\tabstract Class<E> getEnumClass();\n\n\t@Override\n\tpublic Integer convertToDatabaseColumn(Set<E> enumSet)\n\t{\n\t\tvar value = 0;\n\n\t\tif (enumSet != null)\n\t\t{\n\t\t\tfor (Enum<?> anEnum : enumSet)\n\t\t\t{\n\t\t\t\tvalue |= 1 << anEnum.ordinal();\n\t\t\t}\n\t\t}\n\t\treturn value;\n\t}\n\n\t@Override\n\tpublic Set<E> convertToEntityAttribute(Integer value)\n\t{\n\t\tvar e = getEnumClass();\n\n\t\tvar enumSet = EnumSet.noneOf(e);\n\t\tfor (var enumConstant : e.getEnumConstants())\n\t\t{\n\t\t\tif ((value & (1 << enumConstant.ordinal())) != 0)\n\t\t\t{\n\t\t\t\tenumSet.add(enumConstant);\n\t\t\t}\n\t\t}\n\t\treturn enumSet;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/FileTypeConverter.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.common.file.FileType;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class FileTypeConverter extends EnumConverter<FileType>\n{\n\t@Override\n\tClass<FileType> getEnumClass()\n\t{\n\t\treturn FileType.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/GxsCircleTypeConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.database.model.gxs.GxsCircleType;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class GxsCircleTypeConverter extends EnumConverter<GxsCircleType>\n{\n\t@Override\n\tClass<GxsCircleType> getEnumClass()\n\t{\n\t\treturn GxsCircleType.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/GxsPrivacyFlagsConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.database.model.gxs.GxsPrivacyFlags;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class GxsPrivacyFlagsConverter extends EnumSetConverter<GxsPrivacyFlags>\n{\n\t@Override\n\tClass<GxsPrivacyFlags> getEnumClass()\n\t{\n\t\treturn GxsPrivacyFlags.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/GxsSignatureFlagsConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.database.model.gxs.GxsSignatureFlags;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class GxsSignatureFlagsConverter extends EnumSetConverter<GxsSignatureFlags>\n{\n\t@Override\n\tClass<GxsSignatureFlags> getEnumClass()\n\t{\n\t\treturn GxsSignatureFlags.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/IdentityTypeConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.common.identity.Type;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class IdentityTypeConverter extends EnumConverter<Type>\n{\n\t@Override\n\tClass<Type> getEnumClass()\n\t{\n\t\treturn Type.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/NetModeConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.common.protocol.NetMode;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class NetModeConverter extends EnumConverter<NetMode>\n{\n\t@Override\n\tClass<NetMode> getEnumClass()\n\t{\n\t\treturn NetMode.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/PeerAddressTypeConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.net.protocol.PeerAddress;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class PeerAddressTypeConverter extends EnumConverter<PeerAddress.Type>\n{\n\t@Override\n\tClass<PeerAddress.Type> getEnumClass()\n\t{\n\t\treturn PeerAddress.Type.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/SecurityKeyFlagsConverter.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.xrs.common.SecurityKey;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class SecurityKeyFlagsConverter extends EnumSetConverter<SecurityKey.Flags>\n{\n\t@Override\n\tClass<SecurityKey.Flags> getEnumClass()\n\t{\n\t\treturn SecurityKey.Flags.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/SignatureTypeConverter.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.xrs.common.Signature;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class SignatureTypeConverter extends EnumConverter<Signature.Type>\n{\n\t@Override\n\tClass<Signature.Type> getEnumClass()\n\t{\n\t\treturn Signature.Type.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/TrustConverter.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.common.pgp.Trust;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class TrustConverter extends EnumConverter<Trust>\n{\n\t@Override\n\tClass<Trust> getEnumClass()\n\t{\n\t\treturn Trust.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/converter/VoteTypeConverter.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.converter;\n\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport jakarta.persistence.Converter;\n\n@Converter(autoApply = true)\npublic class VoteTypeConverter extends EnumConverter<VoteMessageItem.Type>\n{\n\t@Override\n\tClass<VoteMessageItem.Type> getEnumClass()\n\t{\n\t\treturn VoteMessageItem.Type.class;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/board/BoardMapper.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.board;\n\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.board.item.BoardGroupItem;\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.dto.board.BoardGroupDTO;\nimport io.xeres.common.dto.board.BoardMessageDTO;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class BoardMapper\n{\n\tprivate BoardMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static BoardGroupDTO toDTO(BoardGroupItem item)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new BoardGroupDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getName(),\n\t\t\t\titem.getDescription(),\n\t\t\t\titem.hasImage(),\n\t\t\t\titem.isSubscribed(),\n\t\t\t\titem.isExternal(),\n\t\t\t\titem.getVisibleMessageCount(),\n\t\t\t\titem.getLastActivity()\n\t\t);\n\t}\n\n\tpublic static List<BoardGroupDTO> toDTOs(List<BoardGroupItem> items)\n\t{\n\t\treturn emptyIfNull(items).stream()\n\t\t\t\t.map(BoardMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static BoardMessageDTO toDTO(UnHtmlService unHtmlService, BoardMessageItem item, String authorName, long originalId, long parentId)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new BoardMessageDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getMsgId(),\n\t\t\t\toriginalId,\n\t\t\t\tparentId,\n\t\t\t\titem.getAuthorGxsId(),\n\t\t\t\tauthorName,\n\t\t\t\titem.getName(),\n\t\t\t\titem.getPublished(),\n\t\t\t\titem.getLink(),\n\t\t\t\tunHtmlService.cleanupMessage(item.getContent()),\n\t\t\t\titem.hasImage(),\n\t\t\t\titem.getImageWidth(),\n\t\t\t\titem.getImageHeight(),\n\t\t\t\titem.isRead()\n\t\t);\n\t}\n\n\tpublic static List<BoardMessageDTO> toBoardMessageDTOs(UnHtmlService unHtmlService, Page<BoardMessageItem> items, Map<GxsId, IdentityGroupItem> authorsMap, Map<MsgId, BoardMessageItem> messagesMap)\n\t{\n\t\treturn items.stream()\n\t\t\t\t.map(item -> toDTO(unHtmlService,\n\t\t\t\t\t\titem,\n\t\t\t\t\t\tauthorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getOriginalMsgId(), BoardMessageItem.EMPTY).getId(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getParentMsgId(), BoardMessageItem.EMPTY).getId()\n\t\t\t\t))\n\t\t\t\t.toList();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/channel/ChannelMapper.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.channel;\n\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.app.xrs.service.channel.item.ChannelGroupItem;\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.dto.channel.ChannelFileDTO;\nimport io.xeres.common.dto.channel.ChannelGroupDTO;\nimport io.xeres.common.dto.channel.ChannelMessageDTO;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class ChannelMapper\n{\n\tprivate ChannelMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChannelGroupDTO toDTO(ChannelGroupItem item)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChannelGroupDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getName(),\n\t\t\t\titem.getDescription(),\n\t\t\t\titem.hasImage(),\n\t\t\t\titem.isSubscribed(),\n\t\t\t\titem.isExternal(),\n\t\t\t\titem.getVisibleMessageCount(),\n\t\t\t\titem.getLastActivity()\n\t\t);\n\t}\n\n\tpublic static List<ChannelGroupDTO> toDTOs(List<ChannelGroupItem> items)\n\t{\n\t\treturn emptyIfNull(items).stream()\n\t\t\t\t.map(ChannelMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChannelMessageDTO toDTO(ChannelMessageItem item, String authorName, long originalId, long parentId)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChannelMessageDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getMsgId(),\n\t\t\t\toriginalId,\n\t\t\t\tparentId,\n\t\t\t\titem.getAuthorGxsId(),\n\t\t\t\tauthorName,\n\t\t\t\titem.getName(),\n\t\t\t\titem.getPublished(),\n\t\t\t\tnull,\n\t\t\t\titem.hasImage(),\n\t\t\t\titem.getImageWidth(),\n\t\t\t\titem.getImageHeight(),\n\t\t\t\titem.hasFiles(),\n\t\t\t\tList.of(),\n\t\t\t\titem.isRead()\n\t\t);\n\t}\n\n\tpublic static List<ChannelMessageDTO> toSummaryMessageDTOs(Page<ChannelMessageItem> items, Map<GxsId, IdentityGroupItem> authorsMap, Map<MsgId, ChannelMessageItem> messagesMap)\n\t{\n\t\treturn items.stream()\n\t\t\t\t.map(item -> toDTO(item,\n\t\t\t\t\t\tauthorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getOriginalMsgId(), ChannelMessageItem.EMPTY).getId(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getParentMsgId(), ChannelMessageItem.EMPTY).getId()\n\t\t\t\t))\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChannelMessageDTO toDTO(UnHtmlService unHtmlService, ChannelMessageItem item, String authorName, long originalId, long parentId, boolean withMessageContent)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChannelMessageDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getMsgId(),\n\t\t\t\toriginalId,\n\t\t\t\tparentId,\n\t\t\t\titem.getAuthorGxsId(),\n\t\t\t\tauthorName,\n\t\t\t\titem.getName(),\n\t\t\t\titem.getPublished(),\n\t\t\t\twithMessageContent ? unHtmlService.cleanupMessage(item.getContent()) : \"\",\n\t\t\t\titem.hasImage(),\n\t\t\t\titem.getImageWidth(),\n\t\t\t\titem.getImageHeight(),\n\t\t\t\titem.hasFiles(),\n\t\t\t\twithMessageContent ? toChannelFileDTOs(item.getFiles()) : List.of(),\n\t\t\t\titem.isRead()\n\t\t);\n\t}\n\n\tprivate static List<ChannelFileDTO> toChannelFileDTOs(List<FileItem> files)\n\t{\n\t\treturn emptyIfNull(files).stream()\n\t\t\t\t.map(ChannelMapper::toChannelFileDTO)\n\t\t\t\t.toList();\n\t}\n\n\tprivate static ChannelFileDTO toChannelFileDTO(FileItem item)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new ChannelFileDTO(\n\t\t\t\titem.size(),\n\t\t\t\titem.hash(),\n\t\t\t\titem.name(),\n\t\t\t\titem.path(),\n\t\t\t\titem.age()\n\t\t);\n\t}\n\n\tpublic static List<ChannelMessageDTO> toChannelMessageDTOs(UnHtmlService unHtmlService, List<ChannelMessageItem> items, Map<GxsId, IdentityGroupItem> authorsMap, Map<MsgId, ChannelMessageItem> messagesMap, boolean withMessageContent)\n\t{\n\t\treturn emptyIfNull(items).stream()\n\t\t\t\t.map(item -> toDTO(unHtmlService,\n\t\t\t\t\t\titem,\n\t\t\t\t\t\tauthorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getOriginalMsgId(), ChannelMessageItem.EMPTY).getId(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getParentMsgId(), ChannelMessageItem.EMPTY).getId(),\n\t\t\t\t\t\twithMessageContent\n\t\t\t\t))\n\t\t\t\t.toList();\n\t}\n\n\tpublic static List<FileItem> toFileItems(List<ChannelFileDTO> dtos)\n\t{\n\t\treturn emptyIfNull(dtos).stream()\n\t\t\t\t.map(ChannelMapper::toFileItem)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static FileItem toFileItem(ChannelFileDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new FileItem(dto.size(), dto.hash(), dto.name(), dto.path(), dto.age());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/chat/ChatBacklog.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.app.database.model.location.Location;\nimport jakarta.persistence.*;\nimport org.hibernate.annotations.CreationTimestamp;\n\nimport java.time.Instant;\n\n@Entity\npublic class ChatBacklog\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"location_id\", nullable = false)\n\tprivate Location location;\n\n\t@CreationTimestamp\n\tprivate Instant created;\n\n\tprivate boolean own;\n\n\tprivate String message;\n\n\tprotected ChatBacklog()\n\t{\n\n\t}\n\n\tpublic ChatBacklog(Location location, boolean own, String message)\n\t{\n\t\tthis.location = location;\n\t\tthis.own = own;\n\t\tthis.message = message;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void setLocation(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic Instant getCreated()\n\t{\n\t\treturn created;\n\t}\n\n\tpublic void setCreated(Instant created)\n\t{\n\t\tthis.created = created;\n\t}\n\n\tpublic boolean isOwn()\n\t{\n\t\treturn own;\n\t}\n\n\tpublic void setOwn(boolean own)\n\t{\n\t\tthis.own = own;\n\t}\n\n\tpublic String getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic void setMessage(String message)\n\t{\n\t\tthis.message = message;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/chat/ChatMapper.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.common.dto.chat.*;\nimport io.xeres.common.message.chat.ChatRoomContext;\nimport io.xeres.common.message.chat.ChatRoomInfo;\n\nimport java.util.List;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class ChatMapper\n{\n\tprivate ChatMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatRoomContextDTO toDTO(ChatRoomContext chatRoomContext)\n\t{\n\t\tif (chatRoomContext == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new ChatRoomContextDTO(\n\t\t\t\tnew ChatRoomsDTO(toDTOs(chatRoomContext.chatRoomLists().getSubscribedRooms()), toDTOs(chatRoomContext.chatRoomLists().getAvailableRooms())),\n\t\t\t\tnew ChatIdentityDTO(chatRoomContext.ownUser().nickname(), chatRoomContext.ownUser().gxsId(), chatRoomContext.ownUser().identityId())\n\t\t);\n\t}\n\n\tpublic static List<ChatRoomDTO> toDTOs(List<ChatRoomInfo> chatRoomInfoList)\n\t{\n\t\treturn emptyIfNull(chatRoomInfoList).stream()\n\t\t\t\t.map(ChatMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChatRoomDTO toDTO(ChatRoomInfo chatRoomInfo)\n\t{\n\t\treturn new ChatRoomDTO(\n\t\t\t\tchatRoomInfo.getId(),\n\t\t\t\tchatRoomInfo.getName(),\n\t\t\t\tchatRoomInfo.getRoomType(),\n\t\t\t\tchatRoomInfo.getTopic(),\n\t\t\t\tchatRoomInfo.getCount(),\n\t\t\t\tchatRoomInfo.isSigned()\n\t\t);\n\t}\n\n\tpublic static List<ChatRoomBacklogDTO> toChatRoomBacklogDTOs(List<ChatRoomBacklog> chatRoomBacklogList)\n\t{\n\t\treturn emptyIfNull(chatRoomBacklogList).stream()\n\t\t\t\t.map(ChatMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChatRoomBacklogDTO toDTO(ChatRoomBacklog chatRoomBackLog)\n\t{\n\t\treturn new ChatRoomBacklogDTO(\n\t\t\t\tchatRoomBackLog.getCreated(),\n\t\t\t\tchatRoomBackLog.getGxsId(),\n\t\t\t\tchatRoomBackLog.getNickname(),\n\t\t\t\tchatRoomBackLog.getMessage()\n\t\t);\n\t}\n\n\tpublic static List<ChatBacklogDTO> toChatBacklogDTOs(List<ChatBacklog> chatBacklogList)\n\t{\n\t\treturn emptyIfNull(chatBacklogList).stream()\n\t\t\t\t.map(ChatMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChatBacklogDTO toDTO(ChatBacklog chatBacklog)\n\t{\n\t\treturn new ChatBacklogDTO(\n\t\t\t\tchatBacklog.getCreated(),\n\t\t\t\tchatBacklog.isOwn(),\n\t\t\t\tchatBacklog.getMessage()\n\t\t);\n\t}\n\n\tpublic static List<ChatBacklogDTO> fromDistantChatBacklogToChatBacklogDTOs(List<DistantChatBacklog> distantChatBacklogList)\n\t{\n\t\treturn emptyIfNull(distantChatBacklogList).stream()\n\t\t\t\t.map(ChatMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChatBacklogDTO toDTO(DistantChatBacklog distantChatBacklog)\n\t{\n\t\treturn new ChatBacklogDTO(\n\t\t\t\tdistantChatBacklog.getCreated(),\n\t\t\t\tdistantChatBacklog.isOwn(),\n\t\t\t\tdistantChatBacklog.getMessage()\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/chat/ChatRoom.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport jakarta.persistence.*;\nimport org.apache.commons.lang3.EnumUtils;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\n@Entity\npublic class ChatRoom\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\tprivate long roomId;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"identity_group_id\")\n\tprivate IdentityGroupItem identityGroupItem;\n\n\tprivate String name;\n\tprivate String topic;\n\tprivate int flags;\n\tprivate boolean subscribed;\n\tprivate boolean joined;\n\n\t/**\n\t * Locations that are participating in the chat room.\n\t */\n\t@OneToMany\n\tprivate final Set<Location> locations = new HashSet<>();\n\n\tprotected ChatRoom()\n\t{\n\n\t}\n\n\tprotected ChatRoom(long roomId, IdentityGroupItem identityGroupItem, String name, String topic, int flags)\n\t{\n\t\tthis.roomId = roomId;\n\t\tthis.identityGroupItem = identityGroupItem;\n\t\tthis.name = name;\n\t\tthis.topic = topic;\n\t\tthis.flags = flags;\n\t}\n\n\tpublic static ChatRoom createChatRoom(io.xeres.app.xrs.service.chat.ChatRoom serviceChatRoom, IdentityGroupItem identityGroupItem)\n\t{\n\t\treturn new ChatRoom(serviceChatRoom.getId(),\n\t\t\t\tidentityGroupItem,\n\t\t\t\tserviceChatRoom.getName(),\n\t\t\t\tserviceChatRoom.getTopic(),\n\t\t\t\t(int) EnumUtils.generateBitVector(RoomFlags.class, serviceChatRoom.getRoomFlags()));\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic void setRoomId(long roomId)\n\t{\n\t\tthis.roomId = roomId;\n\t}\n\n\tpublic IdentityGroupItem getGxsIdGroupItem()\n\t{\n\t\treturn identityGroupItem;\n\t}\n\n\tpublic void setGxsIdGroupItem(IdentityGroupItem identityGroupItem)\n\t{\n\t\tthis.identityGroupItem = identityGroupItem;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic String getTopic()\n\t{\n\t\treturn topic;\n\t}\n\n\tpublic void setTopic(String topic)\n\t{\n\t\tthis.topic = topic;\n\t}\n\n\tpublic Set<RoomFlags> getFlags()\n\t{\n\t\treturn EnumUtils.processBitVector(RoomFlags.class, flags);\n\t}\n\n\tpublic void setFlags(Set<RoomFlags> flags)\n\t{\n\t\tthis.flags = (int) EnumUtils.generateBitVector(RoomFlags.class, flags);\n\t}\n\n\tpublic boolean isSubscribed()\n\t{\n\t\treturn subscribed;\n\t}\n\n\tpublic void setSubscribed(boolean subscribed)\n\t{\n\t\tthis.subscribed = subscribed;\n\t}\n\n\tpublic boolean isJoined()\n\t{\n\t\treturn joined;\n\t}\n\n\tpublic void setJoined(boolean joined)\n\t{\n\t\tthis.joined = joined;\n\t}\n\n\tpublic Set<Location> getLocations()\n\t{\n\t\treturn locations;\n\t}\n\n\tpublic void addLocation(Location location)\n\t{\n\t\tlocations.add(location);\n\t}\n\n\tpublic void removeLocation(Location location)\n\t{\n\t\tlocations.remove(location);\n\t}\n\n\tpublic void clearLocations()\n\t{\n\t\tlocations.clear();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/chat/ChatRoomBacklog.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.common.id.GxsId;\nimport jakarta.persistence.*;\nimport org.hibernate.annotations.CreationTimestamp;\n\nimport java.time.Instant;\n\n@Entity\npublic class ChatRoomBacklog\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"room_id\", nullable = false)\n\tprivate ChatRoom room;\n\n\t@CreationTimestamp\n\tprivate Instant created;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"gxs_id\"))\n\tprivate GxsId gxsId;\n\n\tprivate String nickname;\n\n\tprivate String message;\n\n\tprotected ChatRoomBacklog()\n\t{\n\n\t}\n\n\tpublic ChatRoomBacklog(ChatRoom room, GxsId gxsId, String nickname, String message)\n\t{\n\t\tthis.room = room;\n\t\tthis.gxsId = gxsId;\n\t\tthis.nickname = nickname;\n\t\tthis.message = message;\n\t}\n\n\tpublic ChatRoomBacklog(ChatRoom room, String nickname, String message)\n\t{\n\t\tthis.room = room;\n\t\tthis.nickname = nickname;\n\t\tthis.message = message;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic ChatRoom getRoom()\n\t{\n\t\treturn room;\n\t}\n\n\tpublic void setRoom(ChatRoom room)\n\t{\n\t\tthis.room = room;\n\t}\n\n\tpublic Instant getCreated()\n\t{\n\t\treturn created;\n\t}\n\n\tpublic void setCreated(Instant timestamp)\n\t{\n\t\tcreated = timestamp;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic String getNickname()\n\t{\n\t\treturn nickname;\n\t}\n\n\tpublic void setNickname(String nickname)\n\t{\n\t\tthis.nickname = nickname;\n\t}\n\n\tpublic String getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic void setMessage(String message)\n\t{\n\t\tthis.message = message;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/chat/DistantChatBacklog.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport jakarta.persistence.*;\nimport org.hibernate.annotations.CreationTimestamp;\n\nimport java.time.Instant;\n\n@Entity\npublic class DistantChatBacklog\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"identity_id\", nullable = false)\n\tprivate IdentityGroupItem identityGroupItem;\n\n\t@CreationTimestamp\n\tprivate Instant created;\n\n\tprivate boolean own;\n\n\tprivate String message;\n\n\tprotected DistantChatBacklog()\n\t{\n\n\t}\n\n\tpublic DistantChatBacklog(IdentityGroupItem identityGroupItem, boolean own, String message)\n\t{\n\t\tthis.identityGroupItem = identityGroupItem;\n\t\tthis.own = own;\n\t\tthis.message = message;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic IdentityGroupItem getIdentityGroupItem()\n\t{\n\t\treturn identityGroupItem;\n\t}\n\n\tpublic void setIdentityGroupItem(IdentityGroupItem identityGroupItem)\n\t{\n\t\tthis.identityGroupItem = identityGroupItem;\n\t}\n\n\tpublic Instant getCreated()\n\t{\n\t\treturn created;\n\t}\n\n\tpublic void setCreated(Instant created)\n\t{\n\t\tthis.created = created;\n\t}\n\n\tpublic boolean isOwn()\n\t{\n\t\treturn own;\n\t}\n\n\tpublic void setOwn(boolean own)\n\t{\n\t\tthis.own = own;\n\t}\n\n\tpublic String getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic void setMessage(String message)\n\t{\n\t\tthis.message = message;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/connection/Connection.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.connection;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.protocol.ip.IP;\nimport jakarta.persistence.*;\n\nimport java.time.Instant;\nimport java.util.Objects;\n\nimport static io.xeres.app.net.protocol.PeerAddress.Type.HOSTNAME;\nimport static io.xeres.app.net.protocol.PeerAddress.Type.IPV4;\n\n@Entity\npublic class Connection\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"location_id\", nullable = false)\n\tprivate Location location;\n\n\tprivate PeerAddress.Type type;\n\n\tprivate String address;\n\n\tprivate Instant lastConnected;\n\n\tprivate boolean external;\n\n\tprotected Connection()\n\t{\n\t}\n\n\tpublic static Connection from(PeerAddress peerAddress)\n\t{\n\t\treturn new Connection(peerAddress);\n\t}\n\n\tprivate Connection(PeerAddress peerAddress)\n\t{\n\t\ttype = peerAddress.getType();\n\t\taddress = peerAddress.getAddress().orElseThrow();\n\t\texternal = peerAddress.isExternal();\n\t}\n\n\tlong getId()\n\t{\n\t\treturn id;\n\t}\n\n\tvoid setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void setLocation(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic PeerAddress.Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(PeerAddress.Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tpublic String getAddress()\n\t{\n\t\treturn address;\n\t}\n\n\tpublic void setAddress(String address)\n\t{\n\t\tthis.address = address;\n\t}\n\n\tpublic Instant getLastConnected()\n\t{\n\t\treturn lastConnected;\n\t}\n\n\tpublic void setLastConnected(Instant lastConnected)\n\t{\n\t\tthis.lastConnected = lastConnected;\n\t}\n\n\tpublic boolean isExternal()\n\t{\n\t\treturn external;\n\t}\n\n\tpublic void setExternal(boolean external)\n\t{\n\t\tthis.external = external;\n\t}\n\n\t@JsonIgnore\n\tpublic boolean isLan()\n\t{\n\t\treturn type == IPV4 && !isExternal() && IP.isLanIp(address.split(\":\")[0]);\n\t}\n\n\tpublic int getPort()\n\t{\n\t\tif (!(type == IPV4 || type == HOSTNAME))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Trying to get port from a non ipv4 address\");\n\t\t}\n\t\tvar tokens = address.split(\":\");\n\t\treturn Integer.parseInt(tokens[1]);\n\t}\n\n\tpublic String getIp()\n\t{\n\t\tif (type != IPV4)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Trying to get ip from a non ipv4 address\");\n\t\t}\n\t\tvar tokens = address.split(\":\");\n\t\treturn tokens[0];\n\t}\n\n\tpublic String getHostname()\n\t{\n\t\tif (type != HOSTNAME)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Trying to get a hostname from a non hostname address\");\n\t\t}\n\t\tvar tokens = address.split(\":\");\n\t\treturn tokens[0];\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (Connection) o;\n\t\treturn external == that.external && type == that.type && address.equals(that.address);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(type, address, external);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Connection{\" +\n\t\t\t\t\"type=\" + type +\n\t\t\t\t\", address='\" + address + '\\'' +\n\t\t\t\t\", external=\" + external +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/connection/ConnectionMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.connection;\n\nimport io.xeres.common.dto.connection.ConnectionDTO;\n\n@SuppressWarnings(\"DuplicatedCode\")\npublic final class ConnectionMapper\n{\n\tprivate ConnectionMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ConnectionDTO toDTO(Connection connection)\n\t{\n\t\tif (connection == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ConnectionDTO(\n\t\t\t\tconnection.getId(),\n\t\t\t\tconnection.getAddress(),\n\t\t\t\tconnection.getLastConnected(),\n\t\t\t\tconnection.isExternal());\n\t}\n\n\tpublic static Connection fromDTO(ConnectionDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar connection = new Connection();\n\t\tconnection.setId(dto.id());\n\t\tconnection.setAddress(dto.address());\n\t\tconnection.setExternal(dto.external());\n\t\tconnection.setLastConnected(dto.lastConnected());\n\t\treturn connection;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/file/File.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.file;\n\nimport io.xeres.common.file.FileType;\nimport io.xeres.common.id.Sha1Sum;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.time.Instant;\nimport java.util.HashSet;\nimport java.util.Set;\n\n@Entity\npublic class File\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(File.class);\n\n\tprivate static final int NAME_SIZE_MIN = 1;\n\tprivate static final int NAME_SIZE_MAX = 255;\n\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"parent_id\")\n\tprivate File parent;\n\n\t@OneToMany(cascade = CascadeType.ALL, mappedBy = \"parent\", orphanRemoval = true)\n\tprivate Set<File> children = new HashSet<>();\n\n\t@NotNull\n\t@Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX)\n\tprivate String name;\n\n\tprivate long size;\n\n\tprivate FileType type;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"hash\"))\n\tprivate Sha1Sum hash;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"encrypted_hash\"))\n\tprivate Sha1Sum encryptedHash;\n\n\tprivate Instant modified;\n\n\tpublic static File createDirectory(File parent, String name, Instant modified)\n\t{\n\t\tvar file = new File();\n\t\tfile.setParent(parent);\n\t\tfile.setName(name);\n\t\tfile.setType(FileType.DIRECTORY);\n\t\tfile.setModified(modified);\n\t\treturn file;\n\t}\n\n\tpublic static File createFile(File parent, String name, long size, Instant modified)\n\t{\n\t\tvar file = new File();\n\t\tfile.setParent(parent);\n\t\tfile.setName(name);\n\t\tfile.setSize(size);\n\t\tfile.setType(FileType.getTypeByExtension(name));\n\t\tfile.setModified(modified);\n\t\treturn file;\n\t}\n\n\tpublic static File createFile(Path path)\n\t{\n\t\tpath = getCanonicalPath(path);\n\t\tvar file = createFile(path.getRoot().toString(), null);\n\t\tfile.setType(FileType.DIRECTORY);\n\n\t\tfor (Path component : path)\n\t\t{\n\t\t\tfile = createFile(component.getFileName().toString(), file);\n\t\t\tfile.setType(FileType.DIRECTORY);\n\t\t}\n\t\treturn file;\n\t}\n\n\tprivate static Path getCanonicalPath(Path path)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Path.of(path.toFile().getCanonicalPath());\n\t\t}\n\t\tcatch (IOException _)\n\t\t{\n\t\t\tlog.error(\"Failed to get canonical path: {}, using absolute path instead\", path);\n\t\t\treturn path.toAbsolutePath();\n\t\t}\n\t}\n\n\tprivate static File createFile(String name, File parent)\n\t{\n\t\tvar file = new File();\n\t\tfile.setName(name);\n\n\t\tif (parent != null)\n\t\t{\n\t\t\tfile.setParent(parent);\n\t\t}\n\t\treturn file;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic boolean hasParent()\n\t{\n\t\treturn parent != null;\n\t}\n\n\tpublic File getParent()\n\t{\n\t\treturn parent;\n\t}\n\n\tpublic void setParent(File parent)\n\t{\n\t\tthis.parent = parent;\n\t\tparent.getChildren().add(this);\n\t}\n\n\tpublic Set<File> getChildren()\n\t{\n\t\treturn children;\n\t}\n\n\tpublic void setChildren(Set<File> children)\n\t{\n\t\tthis.children = children;\n\t}\n\n\tpublic @NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(@NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic long getSize()\n\t{\n\t\treturn size;\n\t}\n\n\tpublic void setSize(long size)\n\t{\n\t\tthis.size = size;\n\t}\n\n\tpublic FileType getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(FileType type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic void setHash(Sha1Sum hash)\n\t{\n\t\tthis.hash = hash;\n\t}\n\n\tpublic Sha1Sum getEncryptedHash()\n\t{\n\t\treturn encryptedHash;\n\t}\n\n\tpublic void setEncryptedHash(Sha1Sum encryptedHash)\n\t{\n\t\tthis.encryptedHash = encryptedHash;\n\t}\n\n\tpublic Instant getModified()\n\t{\n\t\treturn modified;\n\t}\n\n\tpublic void setModified(Instant modified)\n\t{\n\t\tthis.modified = modified;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"File{\" +\n\t\t\t\t\"id=\" + id +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t\", type=\" + type +\n\t\t\t\t\", hash=\" + hash +\n\t\t\t\t\", encryptedHash=\" + encryptedHash +\n\t\t\t\t\", modified=\" + modified +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/file/FileDownload.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.file;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport java.util.BitSet;\n\n@Entity\npublic class FileDownload\n{\n\tprivate static final int NAME_SIZE_MIN = 1;\n\tprivate static final int NAME_SIZE_MAX = 255;\n\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@NotNull\n\t@Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX)\n\tprivate String name;\n\n\tprivate long size;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"hash\"))\n\tprivate Sha1Sum hash;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"location_id\")\n\tprivate Location location;\n\n\tprivate BitSet chunkMap = new BitSet();\n\n\tprivate boolean completed;\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic @NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(@NotNull @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX) String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic long getSize()\n\t{\n\t\treturn size;\n\t}\n\n\tpublic void setSize(long size)\n\t{\n\t\tthis.size = size;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic void setHash(Sha1Sum hash)\n\t{\n\t\tthis.hash = hash;\n\t}\n\n\tpublic BitSet getChunkMap()\n\t{\n\t\treturn chunkMap;\n\t}\n\n\tpublic void setChunkMap(BitSet chunkMap)\n\t{\n\t\tthis.chunkMap = chunkMap;\n\t}\n\n\tpublic boolean hasLocation()\n\t{\n\t\treturn location != null;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void setLocation(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic boolean isCompleted()\n\t{\n\t\treturn completed;\n\t}\n\n\tpublic void setCompleted(boolean completed)\n\t{\n\t\tthis.completed = completed;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileDownload{\" +\n\t\t\t\t\"id=\" + id +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t\", size=\" + size +\n\t\t\t\t\", hash=\" + hash +\n\t\t\t\t\", chunkMap=\" + chunkMap +\n\t\t\t\t\", location=\" + location +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/forum/ForumMapper.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.forum;\n\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.forum.item.ForumGroupItem;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.dto.forum.ForumGroupDTO;\nimport io.xeres.common.dto.forum.ForumMessageDTO;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class ForumMapper\n{\n\tprivate ForumMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ForumGroupDTO toDTO(ForumGroupItem item)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ForumGroupDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getName(),\n\t\t\t\titem.getDescription(),\n\t\t\t\titem.isSubscribed(),\n\t\t\t\titem.isExternal(),\n\t\t\t\titem.getVisibleMessageCount(),\n\t\t\t\titem.getLastActivity()\n\t\t);\n\t}\n\n\tpublic static List<ForumGroupDTO> toDTOs(List<ForumGroupItem> items)\n\t{\n\t\treturn emptyIfNull(items).stream()\n\t\t\t\t.map(ForumMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ForumMessageDTO toDTO(ForumMessageItemSummary item, String authorName, long originalId, long parentId)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ForumMessageDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getMsgId(),\n\t\t\t\toriginalId,\n\t\t\t\tparentId,\n\t\t\t\titem.getAuthorGxsId(),\n\t\t\t\tauthorName,\n\t\t\t\titem.getName(),\n\t\t\t\titem.getPublished(),\n\t\t\t\tnull,\n\t\t\t\titem.isRead()\n\t\t);\n\t}\n\n\tpublic static List<ForumMessageDTO> toSummaryMessageDTOs(Page<ForumMessageItemSummary> items, Map<GxsId, IdentityGroupItem> authorsMap, Map<MsgId, ForumMessageItem> messagesMap)\n\t{\n\t\treturn items.stream()\n\t\t\t\t.map(item -> toDTO(item,\n\t\t\t\t\t\tauthorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getOriginalMsgId(), ForumMessageItem.EMPTY).getId(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getParentMsgId(), ForumMessageItem.EMPTY).getId()\n\t\t\t\t))\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ForumMessageDTO toDTO(UnHtmlService unHtmlService, ForumMessageItem item, String authorName, long originalId, long parentId, boolean withMessageContent)\n\t{\n\t\tif (item == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ForumMessageDTO(\n\t\t\t\titem.getId(),\n\t\t\t\titem.getGxsId(),\n\t\t\t\titem.getMsgId(),\n\t\t\t\toriginalId,\n\t\t\t\tparentId,\n\t\t\t\titem.getAuthorGxsId(),\n\t\t\t\tauthorName,\n\t\t\t\titem.getName(),\n\t\t\t\titem.getPublished(),\n\t\t\t\twithMessageContent ? unHtmlService.cleanupMessage(item.getContent()) : \"\",\n\t\t\t\titem.isRead()\n\t\t);\n\t}\n\n\tpublic static List<ForumMessageDTO> toForumMessageDTOs(UnHtmlService unHtmlService, List<ForumMessageItem> items, Map<GxsId, IdentityGroupItem> authorsMap, Map<MsgId, ForumMessageItem> messagesMap, boolean withMessageContent)\n\t{\n\t\treturn emptyIfNull(items).stream()\n\t\t\t\t.map(item -> toDTO(unHtmlService,\n\t\t\t\t\t\titem,\n\t\t\t\t\t\tauthorsMap.getOrDefault(item.getAuthorGxsId(), IdentityGroupItem.EMPTY).getName(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getOriginalMsgId(), ForumMessageItem.EMPTY).getId(),\n\t\t\t\t\t\tmessagesMap.getOrDefault(item.getParentMsgId(), ForumMessageItem.EMPTY).getId(),\n\t\t\t\t\t\twithMessageContent\n\t\t\t\t))\n\t\t\t\t.toList();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/forum/ForumMessageItemSummary.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.forum;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\nimport java.time.Instant;\n\n/**\n * A summary of message items.\n * <p>\n * Caution: the method names must match the ones in ForumMessageItem!\n */\npublic interface ForumMessageItemSummary\n{\n\tlong getId();\n\n\tGxsId getGxsId();\n\n\tMsgId getMsgId();\n\n\tMsgId getOriginalMsgId();\n\n\tMsgId getParentMsgId();\n\n\tGxsId getAuthorGxsId();\n\n\tString getName();\n\n\tInstant getPublished();\n\n\tboolean isRead();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsCircleType.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\npublic enum GxsCircleType\n{\n\t/**\n\t * Uninitialized value. For example, Identities are left at that state.\n\t */\n\tUNKNOWN,\n\n\t/**\n\t * Public distribution. Not restricted to a circle.\n\t */\n\tPUBLIC,\n\n\t/**\n\t * Restricted to an external circle, based on GxsIds.\n\t */\n\tEXTERNAL,\n\n\t/**\n\t * Restricted to a group of friend nodes. The administrator of the circle behaves as a controlling hub\n\t * for them. Based on PGP ids.\n\t */\n\tYOUR_FRIENDS_ONLY,\n\n\t/**\n\t * Not distributed at all.\n\t */\n\tLOCAL,\n\n\t/**\n\t * Self-restricted. Used only at creation time of self-restricted circles, when the\n\t * circle ID isn't known yet. Once the circle ID is known, the type\n\t * is set to EXTERNAL, and the external circle ID is set to the ID of the circle itself.\n\t * Based on GxsIds.\n\t */\n\tEXTERNAL_SELF,\n\n\t/**\n\t * Distributed to locations signed by own profile only.\n\t */\n\tYOUR_EYES_ONLY\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsClientUpdate.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.GxsId;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\n\nimport java.time.Instant;\nimport java.util.HashMap;\nimport java.util.Map;\n\n@Entity\npublic class GxsClientUpdate\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@NotNull\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"location_id\", nullable = false)\n\tprivate Location location;\n\n\tprivate int serviceType;\n\n\tprivate Instant lastSynced;\n\n\t@ElementCollection\n\t@Column(name = \"updated\")\n\tprivate final Map<GxsId, Instant> messages = new HashMap<>();\n\n\tpublic GxsClientUpdate()\n\t{\n\t\t// Needed\n\t}\n\n\tpublic GxsClientUpdate(Location location, int serviceType, Instant lastSynced)\n\t{\n\t\tthis.location = location;\n\t\tthis.serviceType = serviceType;\n\t\tthis.lastSynced = lastSynced;\n\t}\n\n\tpublic GxsClientUpdate(Location location, int serviceType, GxsId gxsId, Instant lastSynced)\n\t{\n\t\tthis.location = location;\n\t\tthis.serviceType = serviceType;\n\t\tmessages.put(gxsId, lastSynced);\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void setLocation(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\tpublic void setServiceType(int serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\tpublic Instant getLastSynced()\n\t{\n\t\treturn lastSynced;\n\t}\n\n\tpublic void setLastSynced(Instant lastSynced)\n\t{\n\t\tthis.lastSynced = lastSynced;\n\t}\n\n\tpublic Instant getMessageUpdate(GxsId gxsId)\n\t{\n\t\treturn messages.get(gxsId);\n\t}\n\n\tpublic void putMessageUpdate(GxsId gxsId, Instant lastSynced)\n\t{\n\t\tmessages.put(gxsId, lastSynced);\n\t}\n\n\tpublic void removeMessageUpdate(GxsId gxsId)\n\t{\n\t\tmessages.remove(gxsId);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsClientUpdate{\" +\n\t\t\t\t\"location=\" + location +\n\t\t\t\t\", serviceType=\" + serviceType +\n\t\t\t\t\", lastSynced=\" + lastSynced +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsConstants.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nfinal class GxsConstants\n{\n\tstatic final int GXS_ITEM_MAX_SIZE = 1_572_864; // 1.5 MB\n\n\tprivate GxsConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsGroupItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.xrs.common.SecurityKey;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.FieldSize;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.app.xrs.service.gxs.item.DynamicServiceType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.PrivateKey;\nimport java.security.PublicKey;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.Instant;\nimport java.util.EnumSet;\nimport java.util.HashSet;\nimport java.util.Objects;\nimport java.util.Set;\n\nimport static io.xeres.app.database.model.gxs.GxsConstants.GXS_ITEM_MAX_SIZE;\nimport static io.xeres.app.xrs.common.SecurityKey.Flags.*;\nimport static io.xeres.app.xrs.serialization.Serializer.*;\n\n@Entity(name = \"gxs_group\")\n@Inheritance(strategy = InheritanceType.JOINED)\npublic abstract class GxsGroupItem extends Item implements GxsMetaAndData, DynamicServiceType\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GxsGroupItem.class);\n\n\tprivate static final int API_VERSION_1 = 0x0000;\n\tprivate static final int API_VERSION_2 = 0xaf01;\n\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"gxs_id\"))\n\tprivate GxsId gxsId;\n\n\t@NotNull\n\tprivate String name;\n\n\tprivate Set<GxsPrivacyFlags> diffusionFlags = EnumSet.noneOf(GxsPrivacyFlags.class);\n\n\tprivate Set<GxsSignatureFlags> signatureFlags = EnumSet.noneOf(GxsSignatureFlags.class); // what signatures are required for parent and child messages\n\n\t/**\n\t * The last time the group was updated. This only concerns the group's data (name, image),\n\t * not its children (messages). For example when a new message arrives, this field is not\n\t * updated.\n\t */\n\tprivate Instant published;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"author\"))\n\tprivate GxsId authorGxsId; // author of the group, null if anonymous\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"circle_id\"))\n\tprivate GxsId circleGxsId; // id of the circle to which the group is restricted\n\n\tprivate GxsCircleType circleType = GxsCircleType.UNKNOWN;\n\n\tprivate int authenticationFlags; // not used yet?\n\n\t/**\n\t * Not used by RS currently.\n\t */\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"parent_id\"))\n\tprivate GxsId parentGxsId;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"originator\"))\n\tprivate LocationIdentifier originator;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"internal_circle\"))\n\tprivate GxsId internalCircleGxsId;\n\n\t@ElementCollection\n\tprivate final Set<SecurityKey> privateKeys = HashSet.newHashSet(2);\n\n\t@ElementCollection\n\tprivate final Set<SecurityKey> publicKeys = HashSet.newHashSet(2);\n\n\t@ElementCollection\n\tprivate final Set<Signature> signatures = HashSet.newHashSet(2);\n\n\t// Below is local data (stored in the database only, not synced)\n\n\t/**\n\t * If we are subscribed to that group.\n\t */\n\tprivate boolean subscribed;\n\n\t/**\n\t * When the group's children were updated, which means a sync is needed.\n\t */\n\tprivate Instant lastUpdated;\n\n\t// The following is handled by the group statistics system\n\n\t/**\n\t * Number of friends that are subscribed.\n\t */\n\tprivate int popularity;\n\n\t/**\n\t * Maximum messages reported by friends.\n\t */\n\tprivate int visibleMessageCount;\n\n\t/**\n\t * Last activity reported by the friends.\n\t */\n\tprivate Instant lastActivity = Instant.EPOCH;\n\n\t/**\n\t * When the last statistics request was sent.\n\t */\n\tprivate Instant lastStatistics = Instant.EPOCH;\n\n\t/**\n\t * Retains the values from a group we're upgrading.\n\t *\n\t * @param oldGroup the group to keep the values from\n\t */\n\tpublic void retainValues(GxsGroupItem oldGroup)\n\t{\n\t\tsetSubscribed(oldGroup.isSubscribed());\n\t\tsetLastUpdated(oldGroup.getLastUpdated());\n\t\tsetPopularity(oldGroup.getPopularity());\n\t\tsetVisibleMessageCount(oldGroup.getVisibleMessageCount());\n\t\tsetLastActivity(oldGroup.getLastActivity());\n\t\tsetLastStatistics(oldGroup.getLastStatistics());\n\t}\n\n\t@Transient\n\tprivate int serviceType;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\t@Override\n\tpublic void setServiceType(int serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic @NotNull String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(@NotNull String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic Set<GxsPrivacyFlags> getDiffusionFlags()\n\t{\n\t\treturn diffusionFlags;\n\t}\n\n\tpublic void setDiffusionFlags(Set<GxsPrivacyFlags> diffusionFlags)\n\t{\n\t\tthis.diffusionFlags = diffusionFlags;\n\t}\n\n\tpublic Set<GxsSignatureFlags> getSignatureFlags()\n\t{\n\t\treturn signatureFlags;\n\t}\n\n\tpublic void setSignatureFlags(Set<GxsSignatureFlags> signatureFlags)\n\t{\n\t\tthis.signatureFlags = signatureFlags;\n\t}\n\n\tpublic Instant getPublished()\n\t{\n\t\treturn published;\n\t}\n\n\tpublic void updatePublished()\n\t{\n\t\tpublished = Instant.now();\n\t}\n\n\tpublic GxsId getAuthorGxsId()\n\t{\n\t\treturn authorGxsId;\n\t}\n\n\tpublic void setAuthorGxsId(GxsId authorGxsId)\n\t{\n\t\tthis.authorGxsId = authorGxsId;\n\t}\n\n\tpublic GxsId getCircleGxsId()\n\t{\n\t\treturn circleGxsId;\n\t}\n\n\tpublic void setCircleGxsId(GxsId circleGxsId)\n\t{\n\t\tthis.circleGxsId = circleGxsId;\n\t}\n\n\tpublic GxsCircleType getCircleType()\n\t{\n\t\treturn circleType;\n\t}\n\n\tpublic void setCircleType(GxsCircleType circleType)\n\t{\n\t\tthis.circleType = circleType;\n\t}\n\n\tpublic int getAuthenticationFlags()\n\t{\n\t\treturn authenticationFlags;\n\t}\n\n\tpublic void setAuthenticationFlags(int authenticationFlags)\n\t{\n\t\tthis.authenticationFlags = authenticationFlags;\n\t}\n\n\tpublic GxsId getParentGxsId()\n\t{\n\t\treturn parentGxsId;\n\t}\n\n\tpublic void setParentGxsId(GxsId parentGxsId)\n\t{\n\t\tthis.parentGxsId = parentGxsId;\n\t}\n\n\tpublic boolean isSubscribed()\n\t{\n\t\treturn subscribed;\n\t}\n\n\tpublic void setSubscribed(boolean subscribed)\n\t{\n\t\tthis.subscribed = subscribed;\n\t}\n\n\tpublic int getPopularity()\n\t{\n\t\treturn popularity;\n\t}\n\n\tpublic void setPopularity(int popularity)\n\t{\n\t\tthis.popularity = popularity;\n\t}\n\n\tpublic int getVisibleMessageCount()\n\t{\n\t\treturn visibleMessageCount;\n\t}\n\n\tpublic void setVisibleMessageCount(int visibleMessageCount)\n\t{\n\t\tthis.visibleMessageCount = visibleMessageCount;\n\t}\n\n\tpublic Instant getLastUpdated()\n\t{\n\t\treturn lastUpdated;\n\t}\n\n\tpublic void setLastUpdated(Instant lastUpdated)\n\t{\n\t\tthis.lastUpdated = lastUpdated;\n\t}\n\n\tpublic Instant getLastActivity()\n\t{\n\t\treturn lastActivity;\n\t}\n\n\tpublic void setLastActivity(Instant lastActivity)\n\t{\n\t\tthis.lastActivity = lastActivity;\n\t}\n\n\tpublic Instant getLastStatistics()\n\t{\n\t\treturn lastStatistics;\n\t}\n\n\tpublic void setLastStatistics(Instant lastStatistics)\n\t{\n\t\tthis.lastStatistics = lastStatistics;\n\t}\n\n\tpublic LocationIdentifier getOriginator()\n\t{\n\t\treturn originator;\n\t}\n\n\tpublic void setOriginator(LocationIdentifier originator)\n\t{\n\t\tthis.originator = originator;\n\t}\n\n\tpublic GxsId getInternalCircleGxsId()\n\t{\n\t\treturn internalCircleGxsId;\n\t}\n\n\tpublic void setInternalCircleGxsId(GxsId internalCircleGxsId)\n\t{\n\t\tthis.internalCircleGxsId = internalCircleGxsId;\n\t}\n\n\t/**\n\t * Checks if it comes from an external source (meaning: not our own).\n\t *\n\t * @return true if coming from someone else\n\t */\n\tpublic boolean isExternal()\n\t{\n\t\treturn privateKeys.stream()\n\t\t\t\t.noneMatch(securityKey -> securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_ADMIN, TYPE_FULL)));\n\t}\n\n\tpublic PrivateKey getAdminPrivateKey()\n\t{\n\t\tvar privateKey = privateKeys.stream()\n\t\t\t\t.filter(securityKey -> securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_ADMIN, TYPE_FULL)))\n\t\t\t\t.findFirst().orElseThrow();\n\n\t\ttry\n\t\t{\n\t\t\treturn RSA.getPrivateKeyFromPkcs1(privateKey.getData());\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read admin private key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tpublic void setAdminKeys(PrivateKey privateKey, PublicKey publicKey, Instant validFrom, Instant validTo)\n\t{\n\t\tObjects.requireNonNull(validFrom);\n\t\tvar keyId = getGxsId();\n\t\tif (keyId == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"GxsGroupItem has no GxsId for the admin private key\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tvar privateData = RSA.getPrivateKeyAsPkcs1(privateKey);\n\t\t\tvar publicData = RSA.getPublicKeyAsPkcs1(publicKey);\n\n\t\t\tprivateKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_ADMIN, TYPE_FULL), validFrom, validTo, privateData));\n\t\t\tpublicKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_ADMIN, TYPE_PUBLIC_ONLY), validFrom, validTo, publicData));\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read admin private key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tpublic PublicKey getAdminPublicKey()\n\t{\n\t\tvar publicKey = publicKeys.stream()\n\t\t\t\t.filter(securityKey -> isAdminKey(securityKey) && isValidKey(securityKey))\n\t\t\t\t.findFirst().orElse(null);\n\n\t\tif (publicKey == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\treturn RSA.getPublicKeyFromPkcs1(publicKey.getData());\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read admin public key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tprivate static boolean isAdminKey(SecurityKey securityKey)\n\t{\n\t\treturn securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_ADMIN, TYPE_PUBLIC_ONLY));\n\t}\n\n\tpublic PrivateKey getPublishPrivateKey()\n\t{\n\t\tvar privateKey = privateKeys.stream()\n\t\t\t\t.filter(securityKey -> securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_PUBLISHING, TYPE_FULL)))\n\t\t\t\t.findFirst().orElse(null);\n\n\t\tif (privateKey == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\treturn RSA.getPrivateKeyFromPkcs1(privateKey.getData());\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read publish private key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tpublic void setPublishKeys(GxsId keyId, PrivateKey privateKey, PublicKey publicKey, Instant validFrom, Instant validTo)\n\t{\n\t\tObjects.requireNonNull(validFrom);\n\t\tif (keyId == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"GxsGroupItem has no GxsId for the publish private key\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tvar privateData = RSA.getPrivateKeyAsPkcs1(privateKey);\n\t\t\tvar publicData = RSA.getPublicKeyAsPkcs1(publicKey);\n\n\t\t\tprivateKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_PUBLISHING, TYPE_FULL), validFrom, validTo, privateData));\n\t\t\tpublicKeys.add(new SecurityKey(keyId, EnumSet.of(DISTRIBUTION_PUBLISHING, TYPE_PUBLIC_ONLY), validFrom, validTo, publicData));\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read publish private key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tpublic PublicKey getPublishPublicKey()\n\t{\n\t\tvar publicKey = publicKeys.stream()\n\t\t\t\t.filter(securityKey -> isPublishKey(securityKey) && isValidKey(securityKey))\n\t\t\t\t.findFirst().orElse(null);\n\n\t\tif (publicKey == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\treturn RSA.getPublicKeyFromPkcs1(publicKey.getData());\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read publish public key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tprivate static boolean isPublishKey(SecurityKey securityKey)\n\t{\n\t\treturn securityKey.getFlags().containsAll(Set.of(DISTRIBUTION_PUBLISHING, TYPE_PUBLIC_ONLY));\n\t}\n\n\tprivate boolean isValidKey(SecurityKey securityKey)\n\t{\n\t\tif (securityKey.getValidFrom().isAfter(getPublished()))\n\t\t{\n\t\t\tlog.warn(\"Key {} has an invalid creation date that is less recent than the group's creation\", securityKey);\n\t\t\treturn false;\n\t\t}\n\t\treturn true;\n\t}\n\n\tpublic byte[] getAdminSignature()\n\t{\n\t\treturn signatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.ADMIN)\n\t\t\t\t.findFirst()\n\t\t\t\t.map(Signature::getData).orElse(null);\n\t}\n\n\tpublic void setAdminSignature(byte[] adminSignature)\n\t{\n\t\tObjects.requireNonNull(gxsId);\n\t\tsignatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.ADMIN)\n\t\t\t\t.findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with!\n\t\tvar signature = new Signature(Signature.Type.ADMIN, gxsId, adminSignature);\n\t\tsignatures.add(signature);\n\t}\n\n\tpublic byte[] getAuthorSignature()\n\t{\n\t\treturn signatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.AUTHOR)\n\t\t\t\t.findFirst()\n\t\t\t\t.map(Signature::getData).orElse(null);\n\t}\n\n\tpublic void setAuthorSignature(byte[] authorSignature)\n\t{\n\t\tObjects.requireNonNull(authorSignature);\n\t\tsignatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.AUTHOR)\n\t\t\t\t.findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with!\n\t\tvar signature = new Signature(Signature.Type.AUTHOR, authorGxsId, authorSignature);\n\t\tsignatures.add(signature);\n\t}\n\n\t@Override\n\tpublic int writeMetaObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, API_VERSION_2); // current RS API\n\t\tvar sizeOffset = buf.writerIndex();\n\t\tsize += serialize(buf, 0); // write size at the end\n\t\tsize += serialize(buf, gxsId, GxsId.class);\n\t\tsize += serialize(buf, (GxsId) null, GxsId.class); // This is wrongly sent, it's not used at all\n\t\tsize += serialize(buf, parentGxsId, GxsId.class);\n\t\tsize += serialize(buf, TlvType.STR_NONE, name);\n\t\tsize += serialize(buf, diffusionFlags, FieldSize.INTEGER);\n\t\tsize += serialize(buf, (int) published.getEpochSecond());\n\t\tsize += serialize(buf, circleType);\n\t\tsize += serialize(buf, authenticationFlags);\n\t\tsize += serialize(buf, authorGxsId, GxsId.class);\n\t\tsize += serialize(buf, TlvType.STR_NONE, \"\"); // This is wrongly sent, it's supposed to be local storage\n\t\tsize += serialize(buf, circleGxsId, GxsId.class);\n\t\tsize += serialize(buf, TlvType.SIGNATURE_SET, serializationFlags.contains(SerializationFlags.SIGNATURE) ? new HashSet<>() : signatures);\n\t\tsize += serialize(buf, TlvType.SECURITY_KEY_SET, publicKeys);\n\t\tsize += serialize(buf, signatureFlags, FieldSize.INTEGER);\n\t\tbuf.setInt(sizeOffset, size); // write total size\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readMetaObject(ByteBuf buf)\n\t{\n\t\tvar apiVersion = deserializeInt(buf);\n\t\tif (apiVersion != API_VERSION_1 && apiVersion != API_VERSION_2)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported API version \" + apiVersion);\n\t\t}\n\t\tvar size = deserializeInt(buf); // the size\n\t\tif (size > GXS_ITEM_MAX_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Gxs group meta size \" + size + \" is bigger than the maximum of \" + GXS_ITEM_MAX_SIZE);\n\t\t}\n\t\tgxsId = (GxsId) deserializeIdentifier(buf, GxsId.class);\n\t\tdeserializeIdentifier(buf, GxsId.class);\n\t\tparentGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class);\n\t\tname = (String) deserialize(buf, TlvType.STR_NONE);\n\t\tdiffusionFlags = deserializeEnumSet(buf, GxsPrivacyFlags.class, FieldSize.INTEGER);\n\t\tpublished = Instant.ofEpochSecond(deserializeInt(buf));\n\t\tcircleType = deserializeEnum(buf, GxsCircleType.class);\n\t\tauthenticationFlags = deserializeInt(buf);\n\t\tauthorGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class);\n\t\tdeserialize(buf, TlvType.STR_NONE); // RS leaks storage strings there\n\t\tcircleGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class);\n\t\tdeserializeSignatures(buf);\n\t\tdeserializeSecurityKeySet(buf);\n\t\tif (apiVersion == API_VERSION_2)\n\t\t{\n\t\t\tsignatureFlags = deserializeEnumSet(buf, GxsSignatureFlags.class, FieldSize.INTEGER);\n\t\t}\n\t}\n\n\tprivate void deserializeSecurityKeySet(ByteBuf buf)\n\t{\n\t\t@SuppressWarnings(\"unchecked\") var securityKeys = (Set<SecurityKey>) deserialize(buf, TlvType.SECURITY_KEY_SET);\n\t\tsecurityKeys.forEach(securityKey -> {\n\t\t\tif (securityKey.getFlags().contains(TYPE_PUBLIC_ONLY))\n\t\t\t{\n\t\t\t\tpublicKeys.add(securityKey);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Peer tried to send a private key, ignoring\");\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void deserializeSignatures(ByteBuf buf)\n\t{\n\t\t@SuppressWarnings(\"unchecked\") var signatureSet = (Set<Signature>) deserialize(buf, TlvType.SIGNATURE_SET);\n\t\tsignatures.clear();\n\t\tsignatureSet.forEach(signature -> {\n\t\t\tif (signature.getType() == Signature.Type.ADMIN || signature.getType() == Signature.Type.AUTHOR)\n\t\t\t{\n\t\t\t\tsignatures.add(signature);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Unknown signature type: {}\", signature.getType());\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic GxsGroupItem clone()\n\t{\n\t\treturn (GxsGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tGxsGroupItem that = (GxsGroupItem) o;\n\t\treturn Objects.equals(gxsId, that.gxsId);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(gxsId);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"id=\" + id +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", name='\" + StringUtils.truncate(name, 64) + '\\'' +\n\t\t\t\t\", flags=\" + diffusionFlags +\n\t\t\t\t\", signatureFlags=\" + signatureFlags +\n\t\t\t\t\", published=\" + published +\n\t\t\t\t\", authorGxsId=\" + authorGxsId +\n\t\t\t\t\", circleGxsId=\" + circleGxsId +\n\t\t\t\t\", circleType=\" + circleType +\n\t\t\t\t\", authenticationFlags=\" + authenticationFlags +\n\t\t\t\t\", parentGxsId=\" + parentGxsId +\n\t\t\t\t\", isSubscribed=\" + subscribed +\n\t\t\t\t\", popularity=\" + popularity +\n\t\t\t\t\", visibleMessageCount=\" + visibleMessageCount +\n\t\t\t\t\", lastPosted=\" + lastUpdated +\n\t\t\t\t\", originator=\" + originator +\n\t\t\t\t\", internalCircleGxsId=\" + internalCircleGxsId;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsMessageItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.app.xrs.service.gxs.item.DynamicServiceType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport jakarta.persistence.*;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.time.Instant;\nimport java.util.HashSet;\nimport java.util.Objects;\nimport java.util.Set;\n\nimport static io.xeres.app.database.model.gxs.GxsConstants.GXS_ITEM_MAX_SIZE;\nimport static io.xeres.app.xrs.serialization.Serializer.*;\n\n@Entity(name = \"gxs_message\")\n@Inheritance(strategy = InheritanceType.JOINED)\npublic abstract class GxsMessageItem extends Item implements GxsMetaAndData, DynamicServiceType\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GxsMessageItem.class);\n\n\tprivate static final int API_VERSION_1 = 0x0000;\n\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"gxs_id\"))\n\tprivate GxsId gxsId;\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"message_id\"))\n\tprivate MsgId msgId;\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"thread_id\"))\n\tprivate MsgId threadMsgId; // Used for comments and votes (attaches them to a message)\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"parent_id\"))\n\tprivate MsgId parentMsgId;\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"original_message_id\"))\n\tprivate MsgId originalMsgId;\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"author_id\"))\n\tprivate GxsId authorGxsId;\n\n\tprivate String name;\n\n\tprivate Instant published; // publishts (32-bits)\n\n\t// Lower 16-bits are available for services, higher is reserved. Forums and wiki use it.\n\tprivate int flags;\n\n\t// Local storage only, sets the message as hidden because it was superseded by another message (edited)\n\tprivate boolean hidden;\n\n\t@ElementCollection\n\tprivate final Set<Signature> signatures = HashSet.newHashSet(2);\n\n\t@Transient\n\tprivate int serviceType;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\t@Override\n\tpublic void setServiceType(int serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\tpublic void setMsgId(MsgId msgId)\n\t{\n\t\tthis.msgId = msgId;\n\t}\n\n\tpublic MsgId getOriginalMsgId()\n\t{\n\t\treturn msgId.equals(originalMsgId) ? null : originalMsgId; // Shouldn't happen anymore but older versions might have saved the message with the format used by RS\n\t}\n\n\tpublic void setOriginalMsgId(MsgId originalMsgId)\n\t{\n\t\tthis.originalMsgId = originalMsgId;\n\t}\n\n\tpublic MsgId getParentMsgId()\n\t{\n\t\treturn parentMsgId;\n\t}\n\n\tpublic void setParentMsgId(MsgId parentMsgId)\n\t{\n\t\tthis.parentMsgId = parentMsgId;\n\t}\n\n\tpublic boolean isChild()\n\t{\n\t\treturn parentMsgId != null;\n\t}\n\n\tpublic GxsId getAuthorGxsId()\n\t{\n\t\treturn authorGxsId;\n\t}\n\n\tpublic void setAuthorGxsId(GxsId authorGxsId)\n\t{\n\t\tthis.authorGxsId = authorGxsId;\n\t}\n\n\tpublic boolean hasAuthor()\n\t{\n\t\treturn authorGxsId != null;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic Instant getPublished()\n\t{\n\t\treturn published;\n\t}\n\n\tpublic void updatePublished()\n\t{\n\t\tpublished = Instant.now();\n\t}\n\n\tpublic boolean isHidden()\n\t{\n\t\treturn hidden;\n\t}\n\n\tpublic void setHidden(boolean hidden)\n\t{\n\t\tthis.hidden = hidden;\n\t}\n\n\tpublic byte[] getPublishSignature()\n\t{\n\t\treturn signatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.PUBLISH)\n\t\t\t\t.findFirst().orElseThrow().getData();\n\t}\n\n\tpublic void setPublishSignature(byte[] publishSignature)\n\t{\n\t\tsignatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.PUBLISH)\n\t\t\t\t.findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with!\n\t\tvar signature = new Signature(Signature.Type.PUBLISH, gxsId, publishSignature);\n\t\tsignatures.add(signature);\n\t}\n\n\tpublic byte[] getAuthorSignature()\n\t{\n\t\treturn signatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.AUTHOR)\n\t\t\t\t.findFirst().orElseThrow().getData();\n\t}\n\n\tpublic void setAuthorSignature(byte[] authorSignature)\n\t{\n\t\tObjects.requireNonNull(authorGxsId);\n\t\tsignatures.stream()\n\t\t\t\t.filter(signature -> signature.getType() == Signature.Type.AUTHOR)\n\t\t\t\t.findFirst().ifPresent(signatures::remove); // XXX: hack! This is caused because it shouldn't be a set to begin with!\n\t\tvar signature = new Signature(Signature.Type.AUTHOR, authorGxsId, authorSignature);\n\t\tsignatures.add(signature);\n\t}\n\n\t@Override\n\tpublic int writeMetaObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, API_VERSION_1); // API version\n\t\tvar sizeOffset = buf.writerIndex();\n\t\tsize += serialize(buf, 0); // write size at the end\n\t\tsize += serialize(buf, gxsId, GxsId.class);\n\t\tsize += serialize(buf, serializationFlags.contains(SerializationFlags.SIGNATURE) ? null : msgId, MsgId.class);\n\t\tsize += serialize(buf, threadMsgId, MsgId.class);\n\t\tsize += serialize(buf, parentMsgId, MsgId.class);\n\t\tsize += serialize(buf, serializationFlags.contains(SerializationFlags.SIGNATURE) && Objects.equals(msgId, originalMsgId) ? null : originalMsgId, MsgId.class);\n\t\tsize += serialize(buf, authorGxsId, GxsId.class);\n\t\tsize += serialize(buf, TlvType.SIGNATURE_SET, serializationFlags.contains(SerializationFlags.SIGNATURE) ? new HashSet<>() : signatures);\n\t\tsize += serialize(buf, TlvType.STR_NONE, name);\n\t\tsize += serialize(buf, (int) published.getEpochSecond());\n\t\tsize += serialize(buf, flags);\n\t\tbuf.setInt(sizeOffset, size); // write total size\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readMetaObject(ByteBuf buf)\n\t{\n\t\tvar apiVersion = deserializeInt(buf);\n\t\tif (apiVersion != API_VERSION_1)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported API version \" + apiVersion);\n\t\t}\n\t\tvar size = deserializeInt(buf); // the size\n\t\tif (size > GXS_ITEM_MAX_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Gxs message meta size \" + size + \" is bigger than the maximum of \" + GXS_ITEM_MAX_SIZE);\n\t\t}\n\t\tgxsId = (GxsId) deserializeIdentifier(buf, GxsId.class);\n\t\tmsgId = (MsgId) deserializeIdentifier(buf, MsgId.class);\n\t\tthreadMsgId = (MsgId) deserializeIdentifier(buf, MsgId.class);\n\t\tparentMsgId = (MsgId) deserializeIdentifier(buf, MsgId.class);\n\t\toriginalMsgId = (MsgId) deserializeIdentifier(buf, MsgId.class);\n\t\tif (msgId.equals(originalMsgId))\n\t\t{\n\t\t\t// RS does this weird thing, we get rid of it and use null instead.\n\t\t\toriginalMsgId = null;\n\t\t}\n\t\tauthorGxsId = (GxsId) deserializeIdentifier(buf, GxsId.class);\n\t\tdeserializeSignature(buf);\n\t\tname = (String) deserialize(buf, TlvType.STR_NONE);\n\t\tpublished = Instant.ofEpochSecond(deserializeInt(buf));\n\t\tflags = deserializeInt(buf);\n\t}\n\n\tprivate void deserializeSignature(ByteBuf buf)\n\t{\n\t\t@SuppressWarnings(\"unchecked\") var signatureSet = (Set<Signature>) deserialize(buf, TlvType.SIGNATURE_SET);\n\t\tsignatures.clear();\n\t\tsignatureSet.forEach(signature -> {\n\t\t\tif (signature.getType() == Signature.Type.PUBLISH || signature.getType() == Signature.Type.AUTHOR)\n\t\t\t{\n\t\t\t\tsignatures.add(signature);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Unknown signature type: {}\", signature.getType());\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic GxsMessageItem clone()\n\t{\n\t\treturn (GxsMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"id=\" + id +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", msgId=\" + msgId +\n\t\t\t\t\", name='\" + StringUtils.truncate(name, 64) + '\\'' +\n\t\t\t\t\", threadMsgId=\" + threadMsgId +\n\t\t\t\t\", parentMsgId=\" + parentMsgId +\n\t\t\t\t\", originalMsgId=\" + originalMsgId +\n\t\t\t\t\", authorGxsId=\" + authorGxsId +\n\t\t\t\t\", published=\" + published +\n\t\t\t\t\", flags=\" + flags +\n\t\t\t\t\", hidden=\" + hidden;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsMetaAndData.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\n\nimport java.util.Set;\n\npublic interface GxsMetaAndData\n{\n\tint writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags);\n\n\tvoid readDataObject(ByteBuf buf);\n\n\tint writeMetaObject(ByteBuf buf, Set<SerializationFlags> serializationFlags);\n\n\tvoid readMetaObject(ByteBuf buf);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsPrivacyFlags.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\npublic enum GxsPrivacyFlags\n{\n\tPRIVATE, // Key needed to decrypt the publish key\n\tRESTRICTED, // Publish private key needed to publish (eg. channels)\n\tPUBLIC, // Anyone can publish (eg. forums)\n\tUNUSED_4,\n\tUNUSED_5,\n\tUNUSED_6,\n\tUNUSED_7,\n\tUNUSED_8,\n\tSIGNED_ID // ID backed by Profile\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsServiceSetting.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Id;\n\nimport java.time.Instant;\n\n@Entity\npublic class GxsServiceSetting\n{\n\t@Id\n\tprivate int id;\n\n\tprivate Instant lastUpdated;\n\n\tpublic GxsServiceSetting()\n\t{\n\t\t// Needed\n\t}\n\n\tpublic GxsServiceSetting(int id, Instant lastUpdated)\n\t{\n\t\tthis.id = id;\n\t\tthis.lastUpdated = lastUpdated;\n\t}\n\n\tpublic int getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(int id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic Instant getLastUpdated()\n\t{\n\t\treturn lastUpdated;\n\t}\n\n\tpublic void setLastUpdated(Instant lastUpdated)\n\t{\n\t\tthis.lastUpdated = lastUpdated;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsServiceSetting{\" +\n\t\t\t\t\"lastUpdated=\" + lastUpdated +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/gxs/GxsSignatureFlags.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\npublic enum GxsSignatureFlags\n{\n\tENCRYPTED, // unused?\n\tALL_SIGNED, // unused?\n\tTHREAD_HEAD, // unused?\n\tNONE_REQUIRED, // set for all services but never checked\n\tUNUSED_1,\n\tUNUSED_2,\n\tUNUSED_3,\n\tUNUSED_4,\n\tANTI_SPAM,\n\tAUTHENTICATION_REQUIRED, // unused?\n\tIF_NO_PUB_SIGN, // unused\n\tTRACK_MESSAGES, // unused\n\tANTI_SPAM_2\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/identity/IdentityMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.identity;\n\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.dto.identity.IdentityDTO;\n\nimport java.util.List;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class IdentityMapper\n{\n\tprivate IdentityMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static IdentityDTO toDTO(IdentityGroupItem identityGroupItem)\n\t{\n\t\tif (identityGroupItem == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new IdentityDTO(\n\t\t\t\tidentityGroupItem.getId(),\n\t\t\t\tidentityGroupItem.getName(),\n\t\t\t\tidentityGroupItem.getGxsId(),\n\t\t\t\tidentityGroupItem.getPublished(),\n\t\t\t\tidentityGroupItem.getType(),\n\t\t\t\tidentityGroupItem.hasImage()\n\t\t);\n\t}\n\n\tpublic static List<IdentityDTO> toDTOs(List<IdentityGroupItem> identityGroupItems)\n\t{\n\t\treturn emptyIfNull(identityGroupItems).stream()\n\t\t\t\t.map(IdentityMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/location/Location.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.location;\n\nimport io.xeres.app.crypto.rsid.RSId;\nimport io.xeres.app.crypto.rsid.RSIdBuilder;\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.gxs.GxsClientUpdate;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.backup.LocationIdentifierXmlAdapter;\nimport io.xeres.app.service.backup.RSIdXmlAdapter;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.protocol.NetMode;\nimport io.xeres.common.rsid.Type;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.xml.bind.annotation.XmlAccessType;\nimport jakarta.xml.bind.annotation.XmlAccessorType;\nimport jakarta.xml.bind.annotation.XmlAttribute;\nimport jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.stream.Stream;\n\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\nimport static java.util.Comparator.*;\n\n@Entity\n@XmlAccessorType(XmlAccessType.NONE)\npublic class Location implements Comparable<Location>\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"profile_id\", nullable = false)\n\tprivate Profile profile;\n\n\tprivate String name;\n\n\t@Embedded\n\t@NotNull\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"location_identifier\"))\n\tprivate LocationIdentifier locationIdentifier;\n\n\t@OneToMany(cascade = CascadeType.ALL, mappedBy = \"location\", orphanRemoval = true)\n\tprivate final List<Connection> connections = new ArrayList<>();\n\n\t@OneToMany(cascade = CascadeType.ALL, mappedBy = \"location\", orphanRemoval = true)\n\tprivate final List<GxsClientUpdate> clientUpdates = new ArrayList<>();\n\n\tprivate boolean connected;\n\n\tprivate Instant lastConnected;\n\n\tprivate boolean discoverable = true;\n\n\tprivate boolean dht = true;\n\n\tprivate String version;\n\n\tprivate NetMode netMode = NetMode.UNKNOWN;\n\n\tprivate Availability availability = Availability.AVAILABLE; // Do NOT use Availability.OFFLINE, use isConnected() for that\n\n\tprotected Location()\n\t{\n\n\t}\n\n\tprotected Location(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tprotected Location(long id, String name, Profile profile, LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.id = id;\n\t\tthis.name = name;\n\t\tthis.profile = profile;\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\tprotected Location(String name, Profile profile, LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.name = name;\n\t\tthis.profile = profile;\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\tpublic static Location createLocation(RSId rsId)\n\t{\n\t\treturn new Location(rsId);\n\t}\n\n\tpublic static Location createLocation(String name)\n\t{\n\t\treturn new Location(name);\n\t}\n\n\tpublic static Location createLocation(String name, Profile profile, LocationIdentifier locationIdentifier)\n\t{\n\t\treturn new Location(name, profile, locationIdentifier);\n\t}\n\n\tpublic static Location createLocation(String name, LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = new Location(name);\n\t\tlocation.setLocationIdentifier(locationIdentifier);\n\t\treturn location;\n\t}\n\n\tpublic static void addOrUpdateLocations(Profile profile, Location newLocation)\n\t{\n\t\tif (newLocation == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tprofile.getLocations().removeIf(oldLocation -> oldLocation.getLocationIdentifier().equals(newLocation.getLocationIdentifier())); // XXX: don't remove but update if there are additional fields that were gathered before an update (ie. additional IPs)\n\t\tprofile.addLocation(newLocation);\n\t}\n\n\tpublic Location(RSId rsId)\n\t{\n\t\tsetName(rsId.getName());\n\t\tsetLocationIdentifier(rsId.getLocationIdentifier());\n\t\trsId.getDnsName().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress)));\n\t\trsId.getInternalIp().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress)));\n\t\trsId.getExternalIp().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress)));\n\t\trsId.getHiddenNodeAddress().ifPresent(peerAddress -> addConnection(Connection.from(peerAddress)));\n\n\t\trsId.getLocators().forEach(peerAddress -> addConnection(Connection.from(peerAddress)));\n\t}\n\n\t@XmlAttribute\n\t@XmlJavaTypeAdapter(RSIdXmlAdapter.class)\n\t@Transient\n\tpublic RSId getCertificate()\n\t{\n\t\treturn getRsId(Type.CERTIFICATE);\n\t}\n\n\tpublic RSId getRsId(Type type)\n\t{\n\t\tvar builder = new RSIdBuilder(type);\n\n\t\tbuilder.setName(getProfile().getName().getBytes())\n\t\t\t\t.setProfile((getProfile()))\n\t\t\t\t.setLocationIdentifier(getLocationIdentifier())\n\t\t\t\t.setPgpFingerprint(getProfile().getProfileFingerprint().getBytes());\n\n\t\t// Sort the connections with the most recently connected address first\n\t\tgetConnections().stream()\n\t\t\t\t.sorted(Comparator.comparing(Connection::getLastConnected, Comparator.nullsFirst(Comparator.naturalOrder())).reversed())\n\t\t\t\t.forEach(builder::addLocator);\n\n\t\treturn builder.build();\n\t}\n\n\t/**\n\t * Add a connection while avoiding duplicates.\n\t *\n\t * @param connection the connection to add\n\t */\n\tpublic void addConnection(Connection connection)\n\t{\n\t\tvar connectionAlreadyExists = getConnections().stream()\n\t\t\t\t.filter(existingConnection -> existingConnection.equals(connection))\n\t\t\t\t.findFirst();\n\n\t\tif (connectionAlreadyExists.isEmpty())\n\t\t{\n\t\t\tconnection.setLocation(this);\n\t\t\tgetConnections().add(connection);\n\t\t}\n\t}\n\n\tpublic Profile getProfile()\n\t{\n\t\treturn profile;\n\t}\n\n\tpublic void setProfile(Profile profile)\n\t{\n\t\tthis.profile = profile;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tvoid setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic String getSafeName()\n\t{\n\t\treturn name == null ? \"[Unknown]\" : name;\n\t}\n\n\t/**\n\t * Gets the location name.\n\t *\n\t * @return the location name. Can be null if it was auto created from a profile and is not updated by discovery yet\n\t */\n\t@XmlAttribute\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic boolean isConnected()\n\t{\n\t\treturn connected;\n\t}\n\n\tpublic void setConnected(boolean connected)\n\t{\n\t\tthis.connected = connected;\n\t\tsetLastConnected(Instant.now());\n\t}\n\n\tpublic boolean isDiscoverable()\n\t{\n\t\treturn discoverable;\n\t}\n\n\tpublic void setDiscoverable(boolean discoverable)\n\t{\n\t\tthis.discoverable = discoverable;\n\t}\n\n\tpublic boolean isDht()\n\t{\n\t\treturn dht;\n\t}\n\n\tpublic void setDht(boolean dht)\n\t{\n\t\tthis.dht = dht;\n\t}\n\n\tpublic String getVersion()\n\t{\n\t\treturn version;\n\t}\n\n\tpublic void setVersion(String version)\n\t{\n\t\tthis.version = version;\n\t}\n\n\tpublic NetMode getNetMode()\n\t{\n\t\treturn netMode;\n\t}\n\n\tpublic void setNetMode(NetMode netMode)\n\t{\n\t\tthis.netMode = netMode;\n\t}\n\n\tpublic Availability getAvailability()\n\t{\n\t\treturn availability;\n\t}\n\n\tpublic void setAvailability(Availability availability)\n\t{\n\t\tthis.availability = availability;\n\t}\n\n\tpublic void setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\t@XmlAttribute(name = \"locationId\")\n\t@XmlJavaTypeAdapter(LocationIdentifierXmlAdapter.class)\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic List<Connection> getConnections()\n\t{\n\t\treturn connections;\n\t}\n\n\tpublic List<GxsClientUpdate> getClientUpdates()\n\t{\n\t\treturn clientUpdates;\n\t}\n\n\tpublic Instant getLastConnected()\n\t{\n\t\treturn lastConnected;\n\t}\n\n\tpublic void setLastConnected(Instant lastConnected)\n\t{\n\t\tthis.lastConnected = lastConnected;\n\t}\n\n\tpublic boolean isOwn()\n\t{\n\t\treturn id == OWN_LOCATION_ID;\n\t}\n\n\t/**\n\t * Returns the best connection. Prefers connections most recently connected to and prefers the LAN\n\t * address if the external address is the same as the host.\n\t *\n\t * @param index     index of the connection, is supposed to always increment so that a different connection is returned\n\t * @param ipToAvoid the IP to put last\n\t * @return a connection or empty if none\n\t */\n\tpublic Stream<Connection> getBestConnection(int index, String ipToAvoid)\n\t{\n\t\tif (connections.isEmpty())\n\t\t{\n\t\t\treturn Stream.empty();\n\t\t}\n\t\tvar connectionsSortedByMostReliable = connections.stream()\n\t\t\t\t.sorted(comparing(Location::getConnectionAsIpv4, new OwnIpComparator<>(ipToAvoid))\n\t\t\t\t\t\t.thenComparing(Connection::getLastConnected, nullsLast(reverseOrder())))\n\t\t\t\t.toList();\n\n\t\treturn Stream.of(connectionsSortedByMostReliable.get(index % connectionsSortedByMostReliable.size()));\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar location = (Location) o;\n\t\treturn locationIdentifier.equals(location.locationIdentifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(locationIdentifier);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn name + \" (\" + locationIdentifier + \")\";\n\t}\n\n\tprivate static String getConnectionAsIpv4(Connection connection)\n\t{\n\t\tif (connection.getType() == PeerAddress.Type.IPV4)\n\t\t{\n\t\t\treturn connection.getIp();\n\t\t}\n\t\treturn \"\";\n\t}\n\n\t@Override\n\tpublic int compareTo(Location o)\n\t{\n\t\treturn locationIdentifier.compareTo(o.locationIdentifier);\n\t}\n\n\t/**\n\t * Comparator that puts connections with our own IP as last.\n\t *\n\t * @param <T>\n\t */\n\tstatic final class OwnIpComparator<T> implements Comparator<T>\n\t{\n\t\tprivate final String ipToAvoid;\n\n\t\tOwnIpComparator(String ipToAvoid)\n\t\t{\n\t\t\tthis.ipToAvoid = ipToAvoid;\n\t\t}\n\n\t\t@Override\n\t\tpublic int compare(T o1, T o2)\n\t\t{\n\t\t\tif (o1.equals(ipToAvoid))\n\t\t\t{\n\t\t\t\tif (o2.equals(ipToAvoid))\n\t\t\t\t{\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\treturn 1;\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if (o2.equals(ipToAvoid))\n\t\t\t{\n\t\t\t\treturn -1;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/location/LocationMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.location;\n\nimport io.xeres.app.database.model.connection.ConnectionMapper;\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.id.LocationIdentifier;\n\nimport java.util.ArrayList;\n\n@SuppressWarnings(\"DuplicatedCode\")\npublic final class LocationMapper\n{\n\tprivate LocationMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static LocationDTO toDTO(Location location)\n\t{\n\t\tif (location == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new LocationDTO(\n\t\t\t\tlocation.getId(),\n\t\t\t\tlocation.getName(),\n\t\t\t\tlocation.getLocationIdentifier().getBytes(),\n\t\t\t\tnull,\n\t\t\t\tnew ArrayList<>(),\n\t\t\t\tlocation.isConnected(),\n\t\t\t\tlocation.getLastConnected(),\n\t\t\t\tlocation.getAvailability(),\n\t\t\t\tlocation.getVersion()\n\t\t);\n\t}\n\n\tpublic static LocationDTO toDeepDTO(Location location)\n\t{\n\t\tif (location == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\tvar locationDTO = toDTO(location);\n\n\t\tlocationDTO.connections().addAll(location.getConnections().stream()\n\t\t\t\t.map(ConnectionMapper::toDTO)\n\t\t\t\t.toList());\n\n\t\treturn locationDTO;\n\t}\n\n\tpublic static Location fromDTO(LocationDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar location = new Location();\n\t\tlocation.setId(dto.id());\n\t\tlocation.setName(dto.name());\n\t\tlocation.setLocationIdentifier(new LocationIdentifier(dto.locationIdentifier()));\n\t\tlocation.setConnected(dto.connected());\n\t\tlocation.setLastConnected(dto.lastConnected());\n\t\tlocation.setAvailability(dto.availability());\n\t\tlocation.setVersion(dto.version());\n\t\treturn location;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/profile/Profile.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.profile;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.pgp.Trust;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport jakarta.xml.bind.annotation.XmlAccessType;\nimport jakarta.xml.bind.annotation.XmlAccessorType;\nimport jakarta.xml.bind.annotation.XmlAttribute;\nimport jakarta.xml.bind.annotation.XmlElement;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.bouncycastle.util.encoders.Hex;\n\nimport java.io.IOException;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Comparator;\nimport java.util.List;\n\nimport static io.xeres.common.dto.profile.ProfileConstants.*;\n\n@Entity\n@XmlAccessorType(XmlAccessType.NONE)\npublic class Profile\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@NotNull\n\t@Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX)\n\tprivate String name;\n\n\tprivate long pgpIdentifier;\n\n\tprivate Instant created;\n\n\t@Embedded\n\t@NotNull\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"pgp_fingerprint\"))\n\tprivate ProfileFingerprint profileFingerprint;\n\n\tprivate byte[] pgpPublicKeyData; // if null, this is not a valid profile yet\n\n\tprivate boolean accepted;\n\n\tprivate Trust trust = Trust.UNKNOWN;\n\n\t@OneToMany(cascade = CascadeType.ALL, mappedBy = \"profile\", orphanRemoval = true)\n\tprivate final List<Location> locations = new ArrayList<>();\n\n\tprotected Profile()\n\t{\n\t}\n\n\t// This is only used for unit tests\n\tprotected Profile(long id, String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData)\n\t{\n\t\tthis(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData);\n\t\tthis.id = id;\n\t}\n\n\tpublic static Profile createOwnProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData)\n\t{\n\t\tvar profile = new Profile(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData);\n\t\tprofile.setTrust(Trust.ULTIMATE);\n\t\tprofile.setAccepted(true);\n\t\treturn profile;\n\t}\n\n\tpublic static Profile createProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint pgpFingerprint, PGPPublicKey pgpPublicKey)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn createProfile(name, pgpIdentifier, created, pgpFingerprint, pgpPublicKey.getEncoded());\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic static Profile createProfile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData)\n\t{\n\t\treturn new Profile(name, pgpIdentifier, created, profileFingerprint, pgpPublicKeyData);\n\t}\n\n\tpublic static Profile createEmptyProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint)\n\t{\n\t\treturn new Profile(name, pgpIdentifier, null, profileFingerprint, null);\n\t}\n\n\tprivate Profile(String name, long pgpIdentifier, Instant created, ProfileFingerprint profileFingerprint, byte[] pgpPublicKeyData)\n\t{\n\t\tthis.name = sanitizeProfileName(name);\n\t\tthis.pgpIdentifier = pgpIdentifier;\n\t\tthis.created = created;\n\t\tthis.profileFingerprint = profileFingerprint;\n\t\tthis.pgpPublicKeyData = pgpPublicKeyData;\n\t}\n\n\tprivate static String sanitizeProfileName(String profileName)\n\t{\n\t\tvar index = profileName.indexOf(\" (Generated by\");\n\t\tif (index == -1)\n\t\t{\n\t\t\tindex = profileName.indexOf(\" (generated by\"); // Workaround for some user who had this somehow\n\t\t}\n\t\tif (index > 0)\n\t\t{\n\t\t\treturn profileName.substring(0, index);\n\t\t}\n\t\treturn profileName;\n\t}\n\n\tpublic Profile updateWith(Profile other)\n\t{\n\t\tif (isPartial() && other.isComplete())\n\t\t{\n\t\t\tsetPgpPublicKeyData(other.getPgpPublicKeyData()); // Promote to full profile\n\t\t}\n\t\tLocation.addOrUpdateLocations(this, other.getLocations().stream().findFirst().orElse(null));\n\t\treturn this;\n\t}\n\n\tpublic void addLocation(Location location)\n\t{\n\t\tlocation.setProfile(this);\n\t\tgetLocations().add(location);\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tvoid setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@XmlAttribute\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tvoid setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@XmlAttribute\n\tpublic long getPgpIdentifier()\n\t{\n\t\treturn pgpIdentifier;\n\t}\n\n\tvoid setPgpIdentifier(long pgpIdentifier)\n\t{\n\t\tthis.pgpIdentifier = pgpIdentifier;\n\t}\n\n\tpublic Instant getCreated()\n\t{\n\t\treturn created;\n\t}\n\n\tpublic void setCreated(Instant created)\n\t{\n\t\tthis.created = created;\n\t}\n\n\tpublic ProfileFingerprint getProfileFingerprint()\n\t{\n\t\treturn profileFingerprint;\n\t}\n\n\tpublic void setProfileFingerprint(ProfileFingerprint profileFingerprint)\n\t{\n\t\tthis.profileFingerprint = profileFingerprint;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPgpPublicKeyData()\n\t{\n\t\treturn pgpPublicKeyData;\n\t}\n\n\tpublic void setPgpPublicKeyData(byte[] pgpPublicKeyData)\n\t{\n\t\tthis.pgpPublicKeyData = pgpPublicKeyData;\n\t}\n\n\tpublic boolean isAccepted()\n\t{\n\t\treturn accepted;\n\t}\n\n\tpublic void setAccepted(boolean accepted)\n\t{\n\t\tthis.accepted = accepted;\n\t}\n\n\t@XmlAttribute\n\tpublic Trust getTrust()\n\t{\n\t\treturn trust;\n\t}\n\n\tpublic void setTrust(Trust trust)\n\t{\n\t\tthis.trust = trust;\n\t}\n\n\t@XmlElement(name = \"location\")\n\tpublic List<Location> getLocations()\n\t{\n\t\treturn locations;\n\t}\n\n\tpublic static boolean isOwn(long id)\n\t{\n\t\treturn id == OWN_PROFILE_ID;\n\t}\n\n\tpublic boolean isOwn()\n\t{\n\t\treturn id == OWN_PROFILE_ID;\n\t}\n\n\tpublic boolean isComplete()\n\t{\n\t\treturn pgpPublicKeyData != null;\n\t}\n\n\tpublic boolean isPartial()\n\t{\n\t\treturn pgpPublicKeyData == null;\n\t}\n\n\tpublic boolean isConnected()\n\t{\n\t\treturn getLocations().stream().anyMatch(Location::isConnected);\n\t}\n\n\tpublic Availability getBestAvailability()\n\t{\n\t\treturn getLocations().stream()\n\t\t\t\t.filter(Location::isConnected)\n\t\t\t\t.min(Comparator.comparing(location -> location.getAvailability().ordinal()))\n\t\t\t\t.map(Location::getAvailability)\n\t\t\t\t.orElse(Availability.OFFLINE);\n\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Profile{\" +\n\t\t\t\t\"id=\" + id +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t\", pgpIdentifier=\" + io.xeres.common.id.Id.toString(pgpIdentifier) +\n\t\t\t\t\", profileFingerprint=\" + profileFingerprint +\n\t\t\t\t\", pgpPublicKeyData=\" + new String(Hex.encode(pgpPublicKeyData)) +\n\t\t\t\t\", accepted=\" + accepted +\n\t\t\t\t\", trust=\" + trust +\n\t\t\t\t\", locations=\" + locations +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/profile/ProfileMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.profile;\n\nimport io.xeres.app.database.model.location.LocationMapper;\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\n@SuppressWarnings(\"DuplicatedCode\")\npublic final class ProfileMapper\n{\n\tprivate ProfileMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ProfileDTO toDTO(Profile profile)\n\t{\n\t\tif (profile == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ProfileDTO(\n\t\t\t\tprofile.getId(),\n\t\t\t\tprofile.getName(),\n\t\t\t\tLong.toString(profile.getPgpIdentifier()),\n\t\t\t\tprofile.getCreated(),\n\t\t\t\tprofile.getProfileFingerprint().getBytes(),\n\t\t\t\tprofile.getPgpPublicKeyData(),\n\t\t\t\tprofile.isAccepted(),\n\t\t\t\tprofile.getTrust(),\n\t\t\t\tnew ArrayList<>());\n\t}\n\n\tpublic static ProfileDTO toDeepDTO(Profile profile)\n\t{\n\t\treturn toDeepDTO(profile, null);\n\t}\n\n\tpublic static ProfileDTO toDeepDTO(Profile profile, LocationIdentifier locationIdentifier)\n\t{\n\t\tif (profile == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar profileDTO = toDTO(profile);\n\t\tprofileDTO.locations().addAll(profile.getLocations().stream()\n\t\t\t\t.sorted((o1, o2) -> {\n\t\t\t\t\t// Return the passed location identifier as first. We don't care about the rest\n\t\t\t\t\tif (locationIdentifier != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tif (o1.getLocationIdentifier().equals(locationIdentifier))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\treturn -1;\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse if (o2.getLocationIdentifier().equals(locationIdentifier))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\treturn 1;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn 0;\n\t\t\t\t})\n\t\t\t\t.map(LocationMapper::toDeepDTO)\n\t\t\t\t.toList());\n\t\treturn profileDTO;\n\t}\n\n\n\tpublic static List<ProfileDTO> toDTOs(List<Profile> profiles)\n\t{\n\t\treturn emptyIfNull(profiles).stream()\n\t\t\t\t.map(ProfileMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static List<ProfileDTO> toDeepDTOs(List<Profile> profiles)\n\t{\n\t\treturn emptyIfNull(profiles).stream()\n\t\t\t\t.map(ProfileMapper::toDeepDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static Profile fromDTO(ProfileDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar profile = new Profile();\n\t\tprofile.setId(dto.id());\n\t\tprofile.setName(dto.name());\n\t\tprofile.setPgpIdentifier(Long.parseLong(dto.pgpIdentifier()));\n\t\tprofile.setCreated(dto.created());\n\t\tprofile.setProfileFingerprint(new ProfileFingerprint(dto.pgpFingerprint()));\n\t\tprofile.setPgpPublicKeyData(dto.pgpPublicKeyData());\n\t\tprofile.setAccepted(dto.accepted());\n\t\tprofile.setTrust(dto.trust());\n\t\treturn profile;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/settings/Settings.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.settings;\n\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Id;\nimport jakarta.xml.bind.annotation.XmlAccessType;\nimport jakarta.xml.bind.annotation.XmlAccessorType;\nimport jakarta.xml.bind.annotation.XmlAttribute;\n\n@Entity\n@XmlAccessorType(XmlAccessType.NONE)\npublic class Settings\n{\n\t@SuppressWarnings(\"unused\")\n\t@Id\n\tprivate final byte lock = 1;\n\n\tprivate int version;\n\n\t// The following 5 should not be exposed by JSON. The mapper must ignore them.\n\tprivate byte[] pgpPrivateKeyData;\n\n\tprivate byte[] locationPrivateKeyData;\n\tprivate byte[] locationPublicKeyData;\n\tprivate byte[] locationCertificate;\n\n\tprivate int localPort;\n\n\tprivate String torSocksHost;\n\tprivate int torSocksPort;\n\n\tprivate String i2pSocksHost;\n\tprivate int i2pSocksPort;\n\n\tprivate boolean upnpEnabled;\n\n\tprivate boolean broadcastDiscoveryEnabled;\n\n\tprivate boolean dhtEnabled;\n\n\tprivate boolean autoStartEnabled;\n\n\tprivate String incomingDirectory;\n\n\tprivate String remotePassword;\n\n\tprivate boolean remoteEnabled;\n\n\tprivate boolean upnpRemoteEnabled;\n\n\tprivate int remotePort;\n\n\tpublic Settings()\n\t{\n\t}\n\n\tpublic int getVersion()\n\t{\n\t\treturn version;\n\t}\n\n\tpublic void setVersion(int version)\n\t{\n\t\tthis.version = version;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPgpPrivateKeyData()\n\t{\n\t\treturn pgpPrivateKeyData;\n\t}\n\n\tpublic void setPgpPrivateKeyData(byte[] keyData)\n\t{\n\t\tpgpPrivateKeyData = keyData;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getLocationPrivateKeyData()\n\t{\n\t\treturn locationPrivateKeyData;\n\t}\n\n\tpublic void setLocationPrivateKeyData(byte[] keyData)\n\t{\n\t\tlocationPrivateKeyData = keyData;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getLocationPublicKeyData()\n\t{\n\t\treturn locationPublicKeyData;\n\t}\n\n\tpublic void setLocationPublicKeyData(byte[] keyData)\n\t{\n\t\tlocationPublicKeyData = keyData;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getLocationCertificate()\n\t{\n\t\treturn locationCertificate;\n\t}\n\n\tpublic void setLocationCertificate(byte[] certificate)\n\t{\n\t\tlocationCertificate = certificate;\n\t}\n\n\tpublic boolean hasLocationCertificate()\n\t{\n\t\treturn locationCertificate != null;\n\t}\n\n\tpublic String getTorSocksHost()\n\t{\n\t\treturn torSocksHost;\n\t}\n\n\tpublic void setTorSocksHost(String torSocksHost)\n\t{\n\t\tthis.torSocksHost = torSocksHost;\n\t}\n\n\tpublic int getTorSocksPort()\n\t{\n\t\treturn torSocksPort;\n\t}\n\n\tpublic void setTorSocksPort(int torSocksPort)\n\t{\n\t\tthis.torSocksPort = torSocksPort;\n\t}\n\n\tpublic String getI2pSocksHost()\n\t{\n\t\treturn i2pSocksHost;\n\t}\n\n\tpublic void setI2pSocksHost(String i2pSocksHost)\n\t{\n\t\tthis.i2pSocksHost = i2pSocksHost;\n\t}\n\n\tpublic int getI2pSocksPort()\n\t{\n\t\treturn i2pSocksPort;\n\t}\n\n\tpublic void setI2pSocksPort(int i2pSocksPort)\n\t{\n\t\tthis.i2pSocksPort = i2pSocksPort;\n\t}\n\n\tpublic boolean isUpnpEnabled()\n\t{\n\t\treturn upnpEnabled;\n\t}\n\n\tpublic void setUpnpEnabled(boolean enabled)\n\t{\n\t\tupnpEnabled = enabled;\n\t}\n\n\tpublic boolean isBroadcastDiscoveryEnabled()\n\t{\n\t\treturn broadcastDiscoveryEnabled;\n\t}\n\n\tpublic void setBroadcastDiscoveryEnabled(boolean enabled)\n\t{\n\t\tbroadcastDiscoveryEnabled = enabled;\n\t}\n\n\tpublic boolean isDhtEnabled()\n\t{\n\t\treturn dhtEnabled;\n\t}\n\n\tpublic void setDhtEnabled(boolean dhtEnabled)\n\t{\n\t\tthis.dhtEnabled = dhtEnabled;\n\t}\n\n\t@XmlAttribute\n\tpublic int getLocalPort()\n\t{\n\t\treturn localPort;\n\t}\n\n\tpublic void setLocalPort(int localPort)\n\t{\n\t\tthis.localPort = localPort;\n\t}\n\n\tpublic boolean isAutoStartEnabled()\n\t{\n\t\treturn autoStartEnabled;\n\t}\n\n\tpublic void setAutoStartEnabled(boolean autoStartEnabled)\n\t{\n\t\tthis.autoStartEnabled = autoStartEnabled;\n\t}\n\n\tpublic String getIncomingDirectory()\n\t{\n\t\treturn incomingDirectory;\n\t}\n\n\tpublic void setIncomingDirectory(String incomingDirectory)\n\t{\n\t\tthis.incomingDirectory = incomingDirectory;\n\t}\n\n\tpublic String getRemotePassword()\n\t{\n\t\treturn remotePassword;\n\t}\n\n\tpublic void setRemotePassword(String remotePassword)\n\t{\n\t\tthis.remotePassword = remotePassword;\n\t}\n\n\tpublic boolean isRemoteEnabled()\n\t{\n\t\treturn remoteEnabled;\n\t}\n\n\tpublic void setRemoteEnabled(boolean remoteEnabled)\n\t{\n\t\tthis.remoteEnabled = remoteEnabled;\n\t}\n\n\tpublic boolean isUpnpRemoteEnabled()\n\t{\n\t\treturn upnpRemoteEnabled;\n\t}\n\n\tpublic void setUpnpRemoteEnabled(boolean upnpRemoteEnabled)\n\t{\n\t\tthis.upnpRemoteEnabled = upnpRemoteEnabled;\n\t}\n\n\tpublic int getRemotePort()\n\t{\n\t\treturn remotePort;\n\t}\n\n\tpublic void setRemotePort(int remotePort)\n\t{\n\t\tthis.remotePort = remotePort;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/settings/SettingsMapper.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.settings;\n\nimport io.xeres.common.dto.settings.SettingsDTO;\n\npublic final class SettingsMapper\n{\n\tprivate SettingsMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static SettingsDTO toDTO(Settings settings)\n\t{\n\t\tif (settings == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new SettingsDTO(\n\t\t\t\tsettings.getTorSocksHost(),\n\t\t\t\tsettings.getTorSocksPort(),\n\t\t\t\tsettings.getI2pSocksHost(),\n\t\t\t\tsettings.getI2pSocksPort(),\n\t\t\t\tsettings.isUpnpEnabled(),\n\t\t\t\tsettings.isBroadcastDiscoveryEnabled(),\n\t\t\t\tsettings.isDhtEnabled(),\n\t\t\t\tsettings.isAutoStartEnabled(),\n\t\t\t\tsettings.getIncomingDirectory(),\n\t\t\t\tsettings.getRemotePassword(),\n\t\t\t\tsettings.isRemoteEnabled(),\n\t\t\t\tsettings.isUpnpRemoteEnabled(),\n\t\t\t\tsettings.getRemotePort()\n\t\t);\n\t}\n\n\tpublic static Settings fromDTO(SettingsDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar settings = new Settings();\n\t\tsettings.setTorSocksHost(dto.torSocksHost());\n\t\tsettings.setTorSocksPort(dto.torSocksPort());\n\t\tsettings.setI2pSocksHost(dto.i2pSocksHost());\n\t\tsettings.setI2pSocksPort(dto.i2pSocksPort());\n\t\tsettings.setUpnpEnabled(dto.upnpEnabled());\n\t\tsettings.setBroadcastDiscoveryEnabled(dto.broadcastDiscoveryEnabled());\n\t\tsettings.setDhtEnabled(dto.dhtEnabled());\n\t\tsettings.setAutoStartEnabled(dto.autoStartEnabled());\n\t\tsettings.setIncomingDirectory(dto.incomingDirectory());\n\t\tsettings.setRemotePassword(dto.remotePassword());\n\t\tsettings.setRemoteEnabled(dto.remoteEnabled());\n\t\tsettings.setUpnpRemoteEnabled(dto.upnpRemoteEnabled());\n\t\tsettings.setRemotePort(dto.remotePort());\n\t\treturn settings;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/share/Share.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.share;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.common.pgp.Trust;\nimport jakarta.persistence.*;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport java.time.Instant;\n\nimport static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MAX;\nimport static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MIN;\n\n@Entity\npublic class Share\n{\n\t@Id\n\t@GeneratedValue(strategy = GenerationType.IDENTITY)\n\tprivate long id;\n\n\t@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)\n\t@JoinColumn(name = \"file_id\", nullable = false)\n\tprivate File file;\n\n\t@NotNull\n\t@Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX)\n\tprivate String name;\n\n\tprivate boolean searchable;\n\n\tprivate Trust browsable = Trust.UNKNOWN;\n\n\tprivate Instant lastScanned = Instant.EPOCH;\n\n\tpublic static Share createShare(String name, File directory, boolean searchable, Trust browsable)\n\t{\n\t\tvar share = new Share();\n\t\tshare.setName(name);\n\t\tshare.setFile(directory);\n\t\tshare.setSearchable(searchable);\n\t\tshare.setBrowsable(browsable);\n\t\treturn share;\n\t}\n\n\tprotected Share()\n\t{\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic File getFile()\n\t{\n\t\treturn file;\n\t}\n\n\tpublic void setFile(File file)\n\t{\n\t\tthis.file = file;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic boolean isSearchable()\n\t{\n\t\treturn searchable;\n\t}\n\n\tpublic void setSearchable(boolean searchable)\n\t{\n\t\tthis.searchable = searchable;\n\t}\n\n\tpublic Trust getBrowsable()\n\t{\n\t\treturn browsable;\n\t}\n\n\tpublic void setBrowsable(Trust browsable)\n\t{\n\t\tthis.browsable = browsable;\n\t}\n\n\tpublic Instant getLastScanned()\n\t{\n\t\treturn lastScanned;\n\t}\n\n\tpublic void setLastScanned(Instant lastScanned)\n\t{\n\t\tthis.lastScanned = lastScanned;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Share{\" +\n\t\t\t\t\"id=\" + id +\n\t\t\t\t\", file=\" + file +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t\", searchable=\" + searchable +\n\t\t\t\t\", browsable=\" + browsable +\n\t\t\t\t\", lastScanned=\" + lastScanned +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/model/share/ShareMapper.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.share;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.common.dto.share.ShareDTO;\n\nimport java.nio.file.Paths;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class ShareMapper\n{\n\tprivate ShareMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ShareDTO toDTO(Share share, String path)\n\t{\n\t\tif (share == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ShareDTO(\n\t\t\t\tshare.getId(),\n\t\t\t\tshare.getName(),\n\t\t\t\tpath,\n\t\t\t\tshare.isSearchable(),\n\t\t\t\tshare.getBrowsable(),\n\t\t\t\tshare.getLastScanned()\n\t\t);\n\t}\n\n\tpublic static List<ShareDTO> toDTOs(List<Share> shares, Map<Long, String> filesMap)\n\t{\n\t\treturn emptyIfNull(shares).stream()\n\t\t\t\t.map(share -> toDTO(share, filesMap.get(share.getId())))\n\t\t\t\t.toList();\n\t}\n\n\tpublic static Share fromDTO(ShareDTO shareDTO)\n\t{\n\t\tif (shareDTO == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar share = new Share();\n\t\tshare.setId(shareDTO.id());\n\t\tshare.setName(shareDTO.name());\n\t\tshare.setFile(File.createFile(Paths.get(shareDTO.path())));\n\t\tshare.setSearchable(shareDTO.searchable());\n\t\tshare.setBrowsable(shareDTO.browsable());\n\t\tshare.setLastScanned(shareDTO.lastScanned());\n\t\treturn share;\n\t}\n\n\tpublic static List<Share> fromDTOs(List<ShareDTO> shares)\n\t{\n\t\treturn shares.stream()\n\t\t\t\t.map(ShareMapper::fromDTO)\n\t\t\t\t.toList();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/ChatBacklogRepository.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.chat.ChatBacklog;\nimport io.xeres.app.database.model.location.Location;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\n\n@Transactional(readOnly = true)\npublic interface ChatBacklogRepository extends JpaRepository<ChatBacklog, Long>\n{\n\tList<ChatBacklog> findAllByLocationAndCreatedAfterOrderByCreatedDesc(Location location, Instant from, Limit limit);\n\n\t@Transactional\n\tvoid deleteAllByCreatedBefore(Instant before);\n\n\t@Transactional\n\tvoid deleteAllByLocation(Location location);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/ChatRoomBacklogRepository.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.chat.ChatRoom;\nimport io.xeres.app.database.model.chat.ChatRoomBacklog;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\n\n@Transactional(readOnly = true)\npublic interface ChatRoomBacklogRepository extends JpaRepository<ChatRoomBacklog, Long>\n{\n\tList<ChatRoomBacklog> findAllByRoomAndCreatedAfterOrderByCreatedDesc(ChatRoom chatRoom, Instant from, Limit limit);\n\n\t@Transactional\n\tvoid deleteAllByCreatedBefore(Instant before);\n\n\t@Transactional\n\tvoid deleteAllByRoom(ChatRoom chatRoom);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/ChatRoomRepository.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.chat.ChatRoom;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface ChatRoomRepository extends JpaRepository<ChatRoom, Long>\n{\n\tOptional<ChatRoom> findByRoomIdAndIdentityGroupItem(long roomId, IdentityGroupItem identityGroupItem);\n\n\tOptional<ChatRoom> findByRoomId(long roomId); // Should eventually go away when we have multiple identities but... not sure yet\n\n\tList<ChatRoom> findAllBySubscribedTrueAndJoinedFalse();\n\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE ChatRoom c SET c.joined = false WHERE c.joined = true\")\n\tvoid putAllJoinedToFalse();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/DistantChatBacklogRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.chat.DistantChatBacklog;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\n\n@Transactional(readOnly = true)\npublic interface DistantChatBacklogRepository extends JpaRepository<DistantChatBacklog, Long>\n{\n\tList<DistantChatBacklog> findAllByIdentityGroupItemAndCreatedAfterOrderByCreatedDesc(IdentityGroupItem identityGroupItem, Instant from, Limit limit);\n\n\t@Transactional\n\tvoid deleteAllByCreatedBefore(Instant from);\n\n\t@Transactional\n\tvoid deleteAllByIdentityGroupItem(IdentityGroupItem identityGroupItem);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/FileDownloadRepository.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.file.FileDownload;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface FileDownloadRepository extends JpaRepository<FileDownload, Long>\n{\n\tOptional<FileDownload> findByHash(Sha1Sum hash);\n\n\tList<FileDownload> findAllByLocationIsNull();\n\n\tList<FileDownload> findAllByLocation(Location location);\n\n\t@Transactional\n\tvoid deleteAllByCompletedTrue();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/FileRepository.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.common.id.Sha1Sum;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface FileRepository extends JpaRepository<File, Long>\n{\n\tList<File> findAllByName(String name);\n\n\tList<File> findAllByNameContainingIgnoreCase(String name);\n\n\tOptional<File> findByNameAndParent(String name, File parent);\n\n\tOptional<File> findByNameAndParentName(String name, String parentName);\n\n\tint countByParent(File parent);\n\n\tList<File> findByHash(Sha1Sum hash);\n\n\tList<File> findByEncryptedHash(Sha1Sum encryptedHash);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsBoardGroupRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.service.board.item.BoardGroupItem;\nimport io.xeres.common.id.GxsId;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsBoardGroupRepository extends JpaRepository<BoardGroupItem, Long>\n{\n\tOptional<BoardGroupItem> findByGxsId(GxsId gxsId);\n\n\tList<BoardGroupItem> findAllByGxsIdIn(Set<GxsId> gxsIds);\n\n\tList<BoardGroupItem> findAllBySubscribedIsTrue();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsBoardMessageRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsBoardMessageRepository extends JpaRepository<BoardMessageItem, Long>\n{\n\tOptional<BoardMessageItem> findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId);\n\n\tPage<BoardMessageItem> findAllByGxsIdAndHiddenFalse(GxsId gxsId, Pageable pageable);\n\n\tList<BoardMessageItem> findAllByGxsIdAndPublishedAfterAndHiddenFalse(GxsId gxsId, Instant since);\n\n\tList<BoardMessageItem> findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set<MsgId> msgIds);\n\n\tList<BoardMessageItem> findAllByGxsIdAndMsgIdInAndHiddenFalse(GxsId gxsId, Set<MsgId> msgIds);\n\n\tList<BoardMessageItem> findAllByMsgIdInAndHiddenFalse(Set<MsgId> msgIds);\n\n\t@Query(\"SELECT COUNT(m.id) FROM board_message m WHERE m.gxsId = :gxsId AND m.read = false AND m.hidden = false\")\n\tint countUnreadMessages(GxsId gxsId);\n\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE board_message m SET m.read = :read WHERE m.gxsId = :gxsId AND m.read != :read\")\n\tvoid setAllGroupMessagesReadState(GxsId gxsId, boolean read);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsChannelGroupRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.service.channel.item.ChannelGroupItem;\nimport io.xeres.common.id.GxsId;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsChannelGroupRepository extends JpaRepository<ChannelGroupItem, Long>\n{\n\tOptional<ChannelGroupItem> findByGxsId(GxsId gxsId);\n\n\tList<ChannelGroupItem> findAllByGxsIdIn(Set<GxsId> gxsIds);\n\n\tList<ChannelGroupItem> findAllBySubscribedIsTrue();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsChannelMessageRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsChannelMessageRepository extends JpaRepository<ChannelMessageItem, Long>\n{\n\tOptional<ChannelMessageItem> findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId);\n\n\tPage<ChannelMessageItem> findAllByGxsIdAndHiddenFalse(GxsId gxsId, Pageable pageable);\n\n\tList<ChannelMessageItem> findAllByGxsIdAndPublishedAfterAndHiddenFalse(GxsId gxsId, Instant since);\n\n\tList<ChannelMessageItem> findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set<MsgId> msgIds);\n\n\tList<ChannelMessageItem> findAllByGxsIdAndMsgIdInAndHiddenFalse(GxsId gxsId, Set<MsgId> msgIds);\n\n\tList<ChannelMessageItem> findAllByMsgIdInAndHiddenFalse(Set<MsgId> msgIds);\n\n\t@Query(\"SELECT COUNT(m.id) FROM channel_message m WHERE m.gxsId = :gxsId AND m.read = false AND m.hidden = false\")\n\tint countUnreadMessages(GxsId gxsId);\n\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE channel_message m SET m.read = :read WHERE m.gxsId = :gxsId AND m.read != :read\")\n\tvoid setAllGroupMessagesReadState(GxsId gxsId, boolean read);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsClientUpdateRepository.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.GxsClientUpdate;\nimport io.xeres.app.database.model.location.Location;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface GxsClientUpdateRepository extends JpaRepository<GxsClientUpdate, Long>\n{\n\tOptional<GxsClientUpdate> findByLocationAndServiceType(Location location, int serviceType);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsCommentMessageRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsCommentMessageRepository extends JpaRepository<CommentMessageItem, Long>\n{\n\tList<CommentMessageItem> findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set<MsgId> msgIds);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsForumGroupRepository.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.service.forum.item.ForumGroupItem;\nimport io.xeres.common.id.GxsId;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsForumGroupRepository extends JpaRepository<ForumGroupItem, Long>\n{\n\tOptional<ForumGroupItem> findByGxsId(GxsId gxsId);\n\n\tList<ForumGroupItem> findAllByGxsIdIn(Set<GxsId> gxsIds);\n\n\tList<ForumGroupItem> findAllBySubscribedIsTrue();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsForumMessageRepository.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.forum.ForumMessageItemSummary;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsForumMessageRepository extends JpaRepository<ForumMessageItem, Long>\n{\n\tOptional<ForumMessageItem> findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId);\n\n\tList<ForumMessageItem> findAllByGxsIdAndPublishedAfterAndHiddenFalse(GxsId gxsId, Instant since);\n\n\tList<ForumMessageItem> findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set<MsgId> msgIds);\n\n\tList<ForumMessageItem> findAllByGxsIdAndMsgIdInAndHiddenFalse(GxsId gxsId, Set<MsgId> msgIds);\n\n\tPage<ForumMessageItemSummary> findSummaryAllByGxsIdAndHiddenFalse(GxsId gxsId, Pageable pageable);\n\n\tList<ForumMessageItem> findAllByMsgIdInAndHiddenFalse(Set<MsgId> msgIds);\n\n\tList<ForumMessageItem> findAllByMsgIdInAndHiddenTrue(Set<MsgId> msgIds);\n\n\t@Query(\"SELECT COUNT(m.id) FROM forum_message m WHERE m.gxsId = :gxsId AND m.read = false AND m.hidden = false\")\n\tint countUnreadMessages(GxsId gxsId);\n\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE forum_message m SET m.read = :read WHERE m.gxsId = :gxsId AND m.read != :read\")\n\tvoid setAllGroupMessagesReadState(GxsId gxsId, boolean read);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsGroupItemRepository.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.common.id.GxsId;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface GxsGroupItemRepository extends JpaRepository<GxsGroupItem, Long>\n{\n\tOptional<GxsGroupItem> findByGxsId(GxsId gxsId);\n\n\tOptional<GxsGroupItem> findByGxsIdAndSubscribedIsTrue(GxsId gxsId);\n\n\tList<GxsGroupItem> findByOrderByLastStatistics(Limit limit);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsIdentityRepository.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.identity.Type;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsIdentityRepository extends JpaRepository<IdentityGroupItem, Long>\n{\n\tOptional<IdentityGroupItem> findByGxsId(GxsId gxsId);\n\n\tList<IdentityGroupItem> findAllByGxsIdIn(Set<GxsId> gxsIds);\n\n\tList<IdentityGroupItem> findAllByName(String name);\n\n\tList<IdentityGroupItem> findAllByType(Type type);\n\n\tList<IdentityGroupItem> findAllBySubscribedIsTrue();\n\n\tList<IdentityGroupItem> findAllByNextValidationNotNullAndNextValidationBeforeOrderByNextValidationDesc(Instant now, Limit limit);\n\n\tList<IdentityGroupItem> findAllByProfileId(long profileId);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsMessageItemRepository.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface GxsMessageItemRepository extends JpaRepository<GxsMessageItem, Long>\n{\n\tOptional<GxsMessageItem> findByGxsIdAndMsgId(GxsId gxsId, MsgId msgId);\n\n\tint countByGxsId(GxsId gxsId);\n\n\t/**\n\t * If messages are received out of order, it's possible that we receive a message that replace another (so nothing is done), then we receive that message afterwards.\n\t * We have to check for that our of order message and mark it as hidden.\n\t *\n\t * @param gxsId the message group\n\t * @param since since when to consider the messages\n\t */\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE gxs_message m SET m.hidden = true WHERE m.gxsId = :gxsId AND m.hidden = false AND m.published >= :since AND EXISTS (SELECT 1 FROM gxs_message m2 WHERE m2.gxsId = :gxsId AND m2.msgId != m.msgId AND m2.originalMsgId = m.msgId)\")\n\tvoid fixIntervalDuplicates(GxsId gxsId, Instant since);\n\n\t/**\n\t * Retroshare can branch from a message that is not the latest. We check if there exists another message with the same originalMsgId but with a\n\t * later published timestamp, if so, mark it as hidden because it's not the latest.\n\t *\n\t * @param gxsId the message group\n\t * @param since since when to consider the messages\n\t */\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE gxs_message m SET m.hidden = true WHERE m.gxsId = :gxsId AND m.hidden = false AND m.published >= :since AND m.originalMsgId IS NOT NULL AND EXISTS (SELECT 1 FROM gxs_message m2 WHERE m2.gxsId = :gxsId AND m2.msgId != m.msgId AND m2.originalMsgId = m.originalMsgId AND m2.published > m.published)\")\n\tvoid hideOldDuplicates(GxsId gxsId, Instant since);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsServiceSettingRepository.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.GxsServiceSetting;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\npublic interface GxsServiceSettingRepository extends JpaRepository<GxsServiceSetting, Integer>\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/GxsVoteMessageRepository.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface GxsVoteMessageRepository extends JpaRepository<VoteMessageItem, Long>\n{\n\tList<VoteMessageItem> findAllByGxsIdAndMsgIdIn(GxsId gxsId, Set<MsgId> msgIds);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/LocationRepository.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.LocationIdentifier;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.domain.Slice;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface LocationRepository extends JpaRepository<Location, Long>\n{\n\tOptional<Location> findByLocationIdentifier(LocationIdentifier locationIdentifier);\n\n\tSlice<Location> findAllByConnectedFalse(Pageable pageable);\n\n\tSlice<Location> findAllByConnectedFalseAndDhtTrue(Pageable pageable);\n\n\tList<Location> findAllByConnectedTrue();\n\n\t@Modifying\n\t@Transactional\n\t@Query(\"UPDATE Location l SET l.connected = false WHERE l.connected = true\")\n\tvoid putAllConnectedToFalse();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/ProfileRepository.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\nimport java.util.Optional;\n\n@Transactional(readOnly = true)\npublic interface ProfileRepository extends JpaRepository<Profile, Long>\n{\n\tOptional<Profile> findByName(String name);\n\n\tList<Profile> findAllByNameContaining(String name);\n\n\tOptional<Profile> findByProfileFingerprint(ProfileFingerprint profileFingerprint);\n\n\tOptional<Profile> findByPgpIdentifier(long pgpIdentifier);\n\n\t@Query(\"SELECT p FROM Profile p, IN(p.locations) l WHERE l.locationIdentifier = :locationIdentifier\")\n\tOptional<Profile> findProfileByLocationIdentifier(@Param(\"locationIdentifier\") LocationIdentifier locationIdentifier);\n\n\t@Query(\"SELECT p FROM Profile p, IN(p.locations) l WHERE p.pgpIdentifier = :pgpIdentifier AND p.accepted = true AND p.pgpPublicKeyData is not null AND l.discoverable = true\")\n\tOptional<Profile> findDiscoverableProfileByPgpIdentifier(@Param(\"pgpIdentifier\") long pgpIdentifier);\n\n\t@Query(\"SELECT p FROM Profile p, IN(p.locations) l WHERE p.pgpIdentifier IN (:ids) AND p.accepted = true AND p.pgpPublicKeyData is not null AND l.discoverable = true\")\n\tList<Profile> findAllDiscoverableProfilesByPgpIdentifiers(@Param(\"ids\") Iterable<Long> ids);\n\n\t@Query(\"SELECT p FROM Profile p, IN(p.locations) l WHERE p.accepted = true AND p.pgpPublicKeyData is not null AND l.discoverable = true\")\n\tList<Profile> getAllDiscoverableProfiles();\n\n\t@Query(\"SELECT p FROM Profile p WHERE p.pgpIdentifier IN (:ids) AND p.pgpPublicKeyData is not null\")\n\tList<Profile> findAllCompleteByPgpIdentifiers(@Param(\"ids\") Iterable<Long> ids);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/SettingsRepository.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.settings.Settings;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\npublic interface SettingsRepository extends JpaRepository<Settings, Byte>\n{\n\t@Modifying\n\t@Transactional\n\t@Query(value = \"BACKUP TO :file\", nativeQuery = true)\n\tvoid backupDatabase(@Param(\"file\") String file);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/database/repository/ShareRepository.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.app.database.model.share.Share;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.Optional;\nimport java.util.Set;\n\n@Transactional(readOnly = true)\npublic interface ShareRepository extends JpaRepository<Share, Long>\n{\n\tOptional<Share> findByName(String name);\n\n\tOptional<Share> findShareByFileIdIn(Set<Long> fileId);\n\n\tOptional<Share> findShareByFile(File file);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/job/DhtFinderJob.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.application.events.DhtNodeFoundEvent;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.dht.DhtService;\nimport io.xeres.app.net.peer.bootstrap.PeerTcpClient;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.PeerService;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.data.domain.PageRequest;\nimport org.springframework.data.domain.Slice;\nimport org.springframework.data.domain.Sort;\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.TimeUnit;\n\nimport static java.util.function.Predicate.not;\n\n/**\n * Finds users in the DHT.\n */\n@Component\npublic class DhtFinderJob\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DhtFinderJob.class);\n\n\tprivate static final int SIMULTANEOUS_DHT_LOOKUPS = 1;\n\n\tprivate final LocationService locationService;\n\tprivate final PeerService peerService;\n\tprivate final DhtService dhtService;\n\tprivate final PeerTcpClient peerTcpClient;\n\n\tprivate Slice<Location> locations;\n\tprivate int pageIndex;\n\n\tpublic DhtFinderJob(LocationService locationService, PeerService peerService, DhtService dhtService, PeerTcpClient peerTcpClient)\n\t{\n\t\tthis.locationService = locationService;\n\t\tthis.peerService = peerService;\n\t\tthis.dhtService = dhtService;\n\t\tthis.peerTcpClient = peerTcpClient;\n\t}\n\n\t/**\n\t * After 2 minutes of runtime (which should be enough to get the DHT going), try finding\n\t * unconnected hosts in the DHT, each after 15 seconds.\n\t */\n\t@Scheduled(initialDelay = 120, fixedDelay = 15, timeUnit = TimeUnit.SECONDS)\n\tvoid checkDht()\n\t{\n\t\tif (JobUtils.canRun(peerService) && dhtService.isReady())\n\t\t{\n\t\t\tfindInDht();\n\t\t}\n\t}\n\n\tprivate void findInDht()\n\t{\n\t\tlocations = locationService.getUnconnectedLocationsWithDht(PageRequest.of(getPageIndex(), SIMULTANEOUS_DHT_LOOKUPS, Sort.by(\"lastConnected\")));\n\n\t\tlocations.stream()\n\t\t\t\t.filter(not(Location::isOwn))\n\t\t\t\t.forEach(location -> dhtService.search(location.getLocationIdentifier()));\n\t}\n\n\t@EventListener\n\tpublic void dhtNodeFoundEvent(DhtNodeFoundEvent event)\n\t{\n\t\tvar peerAddress = PeerAddress.from(event.hostPort());\n\t\tlog.debug(\"Trying to connect to location identifier: {} using {} from DHT lookup\", event.locationIdentifier(), event.hostPort());\n\n\t\t// We don't update the connection table of the location here because there's no guarantee that the DHT node that answered is\n\t\t// the right one (could be fake), but discovery will be able to update it.\n\t\tpeerTcpClient.connect(peerAddress);\n\t}\n\n\tprivate int getPageIndex()\n\t{\n\t\tif (locations == null || locations.isLast())\n\t\t{\n\t\t\tpageIndex = 0;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tpageIndex++;\n\t\t}\n\t\treturn pageIndex;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/job/FileIndexingJob.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.service.PeerService;\nimport io.xeres.app.service.file.FileService;\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.TimeUnit;\n\n@Component\npublic class FileIndexingJob\n{\n\tprivate final PeerService peerService;\n\tprivate final FileService fileService;\n\n\tpublic FileIndexingJob(PeerService peerService, FileService fileService)\n\t{\n\t\tthis.peerService = peerService;\n\t\tthis.fileService = fileService;\n\t}\n\n\t@Scheduled(initialDelay = 60, fixedDelay = 30, timeUnit = TimeUnit.SECONDS)\n\tvoid checkFilesToIndex()\n\t{\n\t\tif (JobUtils.canRun(peerService))\n\t\t{\n\t\t\tfileService.checkForSharesToScan();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/job/IdleDetectionJob.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.service.PeerService;\nimport io.xeres.app.xrs.service.status.IdleChecker;\nimport io.xeres.app.xrs.service.status.StatusRsService;\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.common.location.Availability.AVAILABLE;\nimport static io.xeres.common.location.Availability.AWAY;\n\n/**\n * This job changes the status of the user to away or online depending on\n * if he's idle or not.\n */\n@Component\npublic class IdleDetectionJob\n{\n\tprivate static final long IDLE_TIME_MINUTES = 5;\n\n\tprivate final StatusRsService statusRsService;\n\tprivate final PeerService peerService;\n\tprivate final IdleChecker idleChecker;\n\n\tpublic IdleDetectionJob(StatusRsService statusRsService, PeerService peerService, IdleChecker idleChecker)\n\t{\n\t\tthis.statusRsService = statusRsService;\n\t\tthis.peerService = peerService;\n\t\tthis.idleChecker = idleChecker;\n\t}\n\n\t@Scheduled(initialDelay = 5 * 60, fixedDelay = 5, timeUnit = TimeUnit.SECONDS)\n\tvoid checkIdle()\n\t{\n\t\tif (!JobUtils.canRun(peerService))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar idleTime = idleChecker.getIdleTime();\n\t\tif (idleTime < TimeUnit.MINUTES.toSeconds(IDLE_TIME_MINUTES))\n\t\t{\n\t\t\tstatusRsService.changeAvailabilityAutomatically(AVAILABLE);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tstatusRsService.changeAvailabilityAutomatically(AWAY);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/job/JobUtils.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.service.PeerService;\nimport io.xeres.common.util.RemoteUtils;\n\nfinal class JobUtils\n{\n\tprivate JobUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"RedundantIfStatement\")\n\tstatic boolean canRun(PeerService peerService)\n\t{\n\t\t// Do not execute if we're only a remote UI client\n\t\tif (RemoteUtils.isRemoteUiClient())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\t// Do not execute if there's no network or if we're shutting down\n\t\tif (!peerService.isRunning())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/job/PeerConnectionJob.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.bootstrap.PeerI2pClient;\nimport io.xeres.app.net.peer.bootstrap.PeerTcpClient;\nimport io.xeres.app.net.peer.bootstrap.PeerTorClient;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.PeerService;\nimport io.xeres.common.properties.StartupProperties;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Comparator;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.common.properties.StartupProperties.Property.SERVER_ONLY;\n\n/**\n * Handles automatic outgoing connections to peers.\n */\n@Component\npublic class PeerConnectionJob\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(PeerConnectionJob.class);\n\n\tprivate static final int SIMULTANEOUS_CONNECTIONS = 10; // number of locations to connect at once\n\n\tprivate final LocationService locationService;\n\tprivate final PeerTcpClient peerTcpClient;\n\tprivate final PeerTorClient peerTorClient;\n\tprivate final PeerI2pClient peerI2pClient;\n\tprivate final PeerService peerService;\n\n\tpublic PeerConnectionJob(LocationService locationService, PeerTcpClient peerTcpClient, PeerTorClient peerTorClient, PeerI2pClient peerI2pClient, PeerService peerService)\n\t{\n\t\tthis.locationService = locationService;\n\t\tthis.peerTcpClient = peerTcpClient;\n\t\tthis.peerTorClient = peerTorClient;\n\t\tthis.peerI2pClient = peerI2pClient;\n\t\tthis.peerService = peerService;\n\t}\n\n\t@Scheduled(initialDelay = 5, fixedDelay = 60, timeUnit = TimeUnit.SECONDS)\n\tvoid checkConnections()\n\t{\n\t\tconnectToPeers();\n\t}\n\n\tprivate boolean canRun()\n\t{\n\t\t// Also do not execute if we're in server mode (i.e. only accepting connections)\n\t\treturn JobUtils.canRun(peerService) && !StartupProperties.getBoolean(SERVER_ONLY, false);\n\t}\n\n\tprivate void connectToPeers()\n\t{\n\t\tif (!canRun())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsynchronized (PeerConnectionJob.class)\n\t\t{\n\t\t\tvar connections = locationService.getConnectionsToConnectTo(SIMULTANEOUS_CONNECTIONS);\n\n\t\t\tfor (var connection : connections)\n\t\t\t{\n\t\t\t\tconnect(connection);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void connectImmediately(Location location, int connectionIndex)\n\t{\n\t\tif (!canRun())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsynchronized (PeerConnectionJob.class)\n\t\t{\n\t\t\tvar connections = location.getConnections().stream()\n\t\t\t\t\t.sorted(Comparator.comparing(Connection::isExternal).reversed())\n\t\t\t\t\t.toList();\n\n\t\t\tif (!connections.isEmpty())\n\t\t\t{\n\t\t\t\tif (connectionIndex == -1)\n\t\t\t\t{\n\t\t\t\t\tconnect(connections.get(ThreadLocalRandom.current().nextInt(connections.size())));\n\t\t\t\t}\n\t\t\t\telse if (connectionIndex < connections.size())\n\t\t\t\t{\n\t\t\t\t\tconnect(connections.get(connectionIndex));\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Connection index is out of bounds, size: {}, index: {}\", connections.size(), connectionIndex);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void connect(Connection connection)\n\t{\n\t\tlog.debug(\"Attempting to connect to {} ...\", connection.getAddress());\n\t\tvar peerAddress = PeerAddress.fromAddress(connection.getAddress());\n\t\tif (peerAddress.isValid())\n\t\t{\n\t\t\tif (peerAddress.isHidden())\n\t\t\t{\n\t\t\t\tswitch (peerAddress.getType())\n\t\t\t\t{\n\t\t\t\t\tcase TOR -> peerTorClient.connect(peerAddress);\n\t\t\t\t\tcase I2P -> peerI2pClient.connect(peerAddress);\n\t\t\t\t\tdefault -> throw new IllegalArgumentException(\"Wrong type \" + peerAddress.getType() + \" for hidden address\");\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tpeerTcpClient.connect(peerAddress);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.error(\"Automatic connection: invalid address for {}\", connection.getAddress());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/bdisc/BroadcastDiscoveryService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.bdisc;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.common.util.ThreadUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.nio.ByteBuffer;\nimport java.nio.channels.ClosedByInterruptException;\nimport java.nio.channels.DatagramChannel;\nimport java.nio.channels.SelectionKey;\nimport java.nio.channels.Selector;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\n\nimport static io.xeres.common.tray.TrayNotificationType.DISCOVERY;\n\n/**\n * This service periodically sends a UDP broadcast packet to find out\n * if other Retroshare clients are on the LAN. It implements more or\n * less the same protocol as found in the project <a href=\"https://github.com/truvorskameikin/udp-discovery-cpp\">udp-discovery-cpp</a>\n * (which is what Retroshare uses).\n */\n@Service\npublic class BroadcastDiscoveryService implements Runnable\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(BroadcastDiscoveryService.class);\n\n\tprivate static final int PORT = 36405;\n\tprivate static final int APP_ID = 904_571;\n\tprivate static final int BROADCAST_BUFFER_SEND_SIZE_MAX = 512;\n\tprivate static final int BROADCAST_BUFFER_RECV_SIZE = 512;\n\n\tprivate static final Duration LAST_SEEN_TIMEOUT = Duration.ofMinutes(1);\n\n\tprivate static final Duration BROADCAST_MAX_WAIT_TIME = Duration.ofSeconds(5);\n\n\tprivate enum State\n\t{\n\t\tBROADCASTING,\n\t\tWAITING,\n\t\tINTERRUPTED\n\t}\n\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final LocationService locationService;\n\tprivate final UiBridgeService uiBridgeService;\n\n\tprivate InetSocketAddress localAddress;\n\tprivate InetSocketAddress sendAddress;\n\tprivate Thread thread;\n\n\tprivate SocketAddress broadcastSocketAddress;\n\tprivate ByteBuffer sendBuffer;\n\tprivate ByteBuffer receiveBuffer;\n\tprivate State state;\n\tprivate Instant sent = Instant.EPOCH;\n\tprivate int ownPeerId;\n\tprivate final int counter = 1;\n\tprivate final Map<Integer, UdpDiscoveryPeer> peers = new HashMap<>();\n\n\tpublic BroadcastDiscoveryService(DatabaseSessionManager databaseSessionManager, LocationService locationService, UiBridgeService uiBridgeService)\n\t{\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.locationService = locationService;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t}\n\n\tpublic void start(String localIpAddress, int localPort)\n\t{\n\t\tlog.info(\"Starting Broadcast Discovery service...\");\n\t\tlocalAddress = new InetSocketAddress(localIpAddress, localPort);\n\t\tthread = Thread.ofVirtual()\n\t\t\t\t.name(\"Broadcast Discovery Service\")\n\t\t\t\t.start(this);\n\t}\n\n\tpublic void stop()\n\t{\n\t\tif (thread != null)\n\t\t{\n\t\t\tlog.info(\"Stopping Broadcast Discovery...\");\n\t\t\tthread.interrupt();\n\t\t}\n\t}\n\n\tpublic boolean isRunning()\n\t{\n\t\treturn thread.isAlive();\n\t}\n\n\tpublic void waitForTermination()\n\t{\n\t\tThreadUtils.waitForThread(thread);\n\t}\n\n\tprivate static String getBroadcastAddress(String ipAddress)\n\t{\n\t\tList<InetAddress> broadcastList = new ArrayList<>();\n\n\t\tIterator<NetworkInterface> interfaces;\n\t\ttry\n\t\t{\n\t\t\tinterfaces = NetworkInterface.getNetworkInterfaces().asIterator();\n\t\t\twhile (interfaces.hasNext())\n\t\t\t{\n\t\t\t\tvar networkInterface = interfaces.next();\n\t\t\t\tif (networkInterface.isUp() && !networkInterface.isLoopback())\n\t\t\t\t{\n\t\t\t\t\tbroadcastList.addAll(networkInterface.getInterfaceAddresses().stream()\n\t\t\t\t\t\t\t.filter(interfaceAddress -> interfaceAddress.getAddress().getHostAddress().equals(ipAddress))\n\t\t\t\t\t\t\t.map(InterfaceAddress::getBroadcast)\n\t\t\t\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t\t\t\t.toList());\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcatch (SocketException e)\n\t\t{\n\t\t\tlog.error(\"Failed to get broadcast address: {}\", e.getMessage(), e);\n\t\t\treturn null;\n\t\t}\n\t\tif (broadcastList.isEmpty())\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn broadcastList.getFirst().getHostAddress();\n\t}\n\n\tprivate void setupOwnInfo()\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar ownLocation = locationService.findOwnLocation().orElseThrow();\n\n\t\t\townPeerId = ownLocation.getLocationIdentifier().hashCode();\n\t\t\tsendBuffer = UdpDiscoveryProtocol.createPacket(\n\t\t\t\t\tBROADCAST_BUFFER_SEND_SIZE_MAX,\n\t\t\t\t\tUdpDiscoveryPeer.Status.PRESENT,\n\t\t\t\t\tAPP_ID,\n\t\t\t\t\townPeerId,\n\t\t\t\t\tcounter,\n\t\t\t\t\townLocation.getProfile().getProfileFingerprint(),\n\t\t\t\t\townLocation.getLocationIdentifier(),\n\t\t\t\t\tlocalAddress.getPort(),\n\t\t\t\t\townLocation.getProfile().getName());\n\t\t\tsendBuffer.flip();\n\t\t}\n\t}\n\n\t@SuppressWarnings(\"EmptyMethod\")\n\tprivate void updateOwnInfo()\n\t{\n\t\t// For now, we do nothing; but we could implement something better if for\n\t\t// example there's a change of IP or port. Remember to increase the\n\t\t// counter for each update otherwise it won't be taken into account.\n\t\t// But see https://github.com/truvorskameikin/udp-discovery-cpp/issues/18\n\t}\n\n\t@Override\n\tpublic void run()\n\t{\n\t\tvar broadcastAddress = getBroadcastAddress(localAddress.getHostString());\n\t\tif (broadcastAddress == null)\n\t\t{\n\t\t\tlog.error(\"Couldn't get broadcast address, disabling broadcast discovery service\");\n\t\t\treturn;\n\t\t}\n\n\t\tbroadcastSocketAddress = new InetSocketAddress(broadcastAddress, PORT);\n\t\treceiveBuffer = ByteBuffer.allocate(BROADCAST_BUFFER_RECV_SIZE);\n\n\t\tsetupOwnInfo();\n\n\t\ttry (var selector = Selector.open();\n\t\t     var receiveChannel = DatagramChannel.open(StandardProtocolFamily.INET)\n\t\t\t\t     .setOption(StandardSocketOptions.SO_REUSEADDR, true)\n\t\t\t\t     .bind(new InetSocketAddress(localAddress.getHostString(), PORT));\n\t\t     var sendChannel = DatagramChannel.open(StandardProtocolFamily.INET)\n\t\t\t\t     .setOption(StandardSocketOptions.SO_BROADCAST, true)\n\t\t\t\t     .bind(new InetSocketAddress(localAddress.getHostString(), 0))\n\t\t)\n\t\t{\n\t\t\tsendAddress = (InetSocketAddress) sendChannel.getLocalAddress();\n\t\t\treceiveChannel.configureBlocking(false);\n\t\t\treceiveChannel.register(selector, SelectionKey.OP_READ);\n\t\t\tstate = State.BROADCASTING;\n\n\t\t\twhile (true)\n\t\t\t{\n\t\t\t\tif (state == State.BROADCASTING)\n\t\t\t\t{\n\t\t\t\t\tupdateOwnInfo();\n\t\t\t\t\tsendBroadcast(sendChannel);\n\t\t\t\t}\n\n\t\t\t\tselector.select(getSelectorTimeout());\n\t\t\t\tif (Thread.interrupted())\n\t\t\t\t{\n\t\t\t\t\tsetState(State.INTERRUPTED);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\thandleSelection(selector);\n\t\t\t}\n\t\t}\n\t\tcatch (ClosedByInterruptException _)\n\t\t{\n\t\t\tlog.debug(\"Interrupted, bailing out...\");\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Error: \", e);\n\t\t}\n\t}\n\n\tprivate void handleSelection(Selector selector)\n\t{\n\t\tvar selectedKeys = selector.selectedKeys().iterator();\n\n\t\tif (!selectedKeys.hasNext())\n\t\t{\n\t\t\t// This was a timeout\n\t\t\tsetState(State.BROADCASTING);\n\t\t\treturn;\n\t\t}\n\n\t\twhile (selectedKeys.hasNext())\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar key = selectedKeys.next();\n\t\t\t\tselectedKeys.remove();\n\n\t\t\t\tif (!key.isValid())\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (key.isReadable())\n\t\t\t\t{\n\t\t\t\t\tread(key);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.warn(\"Glitch, continuing...\", e);\n\t\t\t}\n\t\t}\n\n\t\t// We're past the timeout so send again\n\t\tif (Duration.between(sent, Instant.now()).compareTo(BROADCAST_MAX_WAIT_TIME) > 0)\n\t\t{\n\t\t\tsetState(State.BROADCASTING);\n\t\t}\n\t}\n\n\tprivate long getSelectorTimeout()\n\t{\n\t\treturn switch (state)\n\t\t\t\t{\n\t\t\t\t\tcase WAITING -> Math.max(BROADCAST_MAX_WAIT_TIME.toMillis() - Duration.between(sent, Instant.now()).toMillis(), 0L);\n\t\t\t\t\tdefault -> 0L;\n\t\t\t\t};\n\t}\n\n\tprivate void setState(State newState)\n\t{\n\t\tstate = newState;\n\t}\n\n\tprivate void read(SelectionKey key) throws IOException\n\t{\n\t\tassert state == State.WAITING;\n\n\t\t@SuppressWarnings(\"resource\") var channel = (DatagramChannel) key.channel();\n\t\tvar peerAddress = (InetSocketAddress) channel.receive(receiveBuffer);\n\t\treceiveBuffer.flip();\n\n\t\tif (!peerAddress.equals(sendAddress)) // ignore our own packets\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar peer = UdpDiscoveryProtocol.parsePacket(receiveBuffer, peerAddress);\n\t\t\t\tlog.debug(\"Got peer: {}\", peer);\n\t\t\t\tvar now = Instant.now();\n\n\t\t\t\tif (isValidPeer(peer))\n\t\t\t\t{\n\t\t\t\t\tvar lastSeenPeer = peers.get(peer.getPeerId());\n\t\t\t\t\tif (lastSeenPeer != null)\n\t\t\t\t\t{\n\t\t\t\t\t\t// If a client is missing for a minute, remove it\n\t\t\t\t\t\tif (lastSeenPeer.getLastSeen().plus(LAST_SEEN_TIMEOUT).isBefore(now))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.debug(\"Removing peer {} because it hasn't been seen for more than a minute\", peer);\n\t\t\t\t\t\t\tpeers.remove(peer.getPeerId());\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlastSeenPeer.setLastSeen(now);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\t// Add. Currently, the protocol's packet index is always incremented so there can't be an optimization to see if there's new data, so we can't update.\n\t\t\t\t\t\tpeers.put(peer.getPeerId(), peer);\n\t\t\t\t\t\tpeer.setLastSeen(now);\n\t\t\t\t\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.debug(\"Trying to update friend's IP\");\n\n\t\t\t\t\t\t\tlocationService.findLocationByLocationIdentifier(peer.getLocationIdentifier()).ifPresentOrElse(location -> {\n\t\t\t\t\t\t\t\tif (!location.isConnected())\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tvar lanConnection = Connection.from(PeerAddress.from(peer.getIpAddress(), peer.getLocalPort()));\n\n\t\t\t\t\t\t\t\t\tlog.debug(\"Updating friend {} with ip {}\", location, lanConnection);\n\t\t\t\t\t\t\t\t\tlocation.addConnection(lanConnection);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}, () -> uiBridgeService.showTrayNotification(DISCOVERY, \"Detected client on LAN: \" + peer.getProfileName() + \" at \" + peer.getIpAddress()));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (RuntimeException e)\n\t\t\t{\n\t\t\t\tlog.debug(\"Failed to parse packet: {}\", e.getMessage());\n\t\t\t}\n\t\t}\n\t\treceiveBuffer.clear();\n\t}\n\n\tprivate void sendBroadcast(DatagramChannel channel) throws IOException\n\t{\n\t\tassert state == State.BROADCASTING;\n\n\t\tchannel.send(sendBuffer, broadcastSocketAddress);\n\t\tsent = Instant.now();\n\t\tsetState(State.WAITING);\n\t\tsendBuffer.rewind();\n\t}\n\n\tprivate boolean isValidPeer(UdpDiscoveryPeer peer)\n\t{\n\t\treturn peer != null && peer.getAppId() == APP_ID && peer.getStatus() == UdpDiscoveryPeer.Status.PRESENT && peer.getPeerId() != ownPeerId;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/bdisc/ProtocolVersion.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.bdisc;\n\nenum ProtocolVersion\n{\n\tVERSION_0,\n\tVERSION_1,\n\tVERSION_2\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/bdisc/UdpDiscoveryPeer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.bdisc;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\n\nimport java.time.Instant;\n\npublic class UdpDiscoveryPeer\n{\n\tpublic enum Status\n\t{\n\t\tPRESENT,\n\t\tLEAVING // Not implemented. I don't see the point\n\t}\n\n\tprivate Status status;\n\tprivate int appId;\n\tprivate int peerId;\n\tprivate long packetIndex;\n\tprivate String ipAddress;\n\n\tprivate ProfileFingerprint fingerprint;\n\tprivate LocationIdentifier locationIdentifier;\n\tprivate int localPort;\n\tprivate String profileName;\n\n\tprivate Instant lastSeen;\n\n\tpublic Status getStatus()\n\t{\n\t\treturn status;\n\t}\n\n\tpublic void setStatus(Status status)\n\t{\n\t\tthis.status = status;\n\t}\n\n\tpublic int getAppId()\n\t{\n\t\treturn appId;\n\t}\n\n\tpublic void setAppId(int appId)\n\t{\n\t\tthis.appId = appId;\n\t}\n\n\tpublic int getPeerId()\n\t{\n\t\treturn peerId;\n\t}\n\n\tpublic void setPeerId(int peerId)\n\t{\n\t\tthis.peerId = peerId;\n\t}\n\n\tpublic long getPacketIndex()\n\t{\n\t\treturn packetIndex;\n\t}\n\n\tpublic void setPacketIndex(long packetIndex)\n\t{\n\t\tthis.packetIndex = packetIndex;\n\t}\n\n\tpublic String getIpAddress()\n\t{\n\t\treturn ipAddress;\n\t}\n\n\tpublic void setIpAddress(String ipAddress)\n\t{\n\t\tthis.ipAddress = ipAddress;\n\t}\n\n\tpublic ProfileFingerprint getFingerprint()\n\t{\n\t\treturn fingerprint;\n\t}\n\n\tpublic void setFingerprint(ProfileFingerprint fingerprint)\n\t{\n\t\tthis.fingerprint = fingerprint;\n\t}\n\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic void setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\tpublic int getLocalPort()\n\t{\n\t\treturn localPort;\n\t}\n\n\tpublic void setLocalPort(int localPort)\n\t{\n\t\tthis.localPort = localPort;\n\t}\n\n\tpublic String getProfileName()\n\t{\n\t\treturn profileName;\n\t}\n\n\tpublic void setProfileName(String profileName)\n\t{\n\t\tthis.profileName = profileName;\n\t}\n\n\tpublic Instant getLastSeen()\n\t{\n\t\treturn lastSeen;\n\t}\n\n\tpublic void setLastSeen(Instant lastSeen)\n\t{\n\t\tthis.lastSeen = lastSeen;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"UdpDiscoveryPeer{\" +\n\t\t\t\t\"status=\" + status +\n\t\t\t\t\", AppId=\" + appId +\n\t\t\t\t\", peerId=\" + peerId +\n\t\t\t\t\", packetIndex=\" + packetIndex +\n\t\t\t\t\", fingerprint=\" + fingerprint +\n\t\t\t\t\", locationIdentifier=\" + locationIdentifier +\n\t\t\t\t\", ipAddress='\" + ipAddress + '\\'' +\n\t\t\t\t\", localPort=\" + localPort +\n\t\t\t\t\", profileName='\" + profileName + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/bdisc/UdpDiscoveryProtocol.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.bdisc;\n\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.net.bdisc.UdpDiscoveryPeer.Status;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.net.InetSocketAddress;\nimport java.nio.ByteBuffer;\n\nimport static io.xeres.app.net.bdisc.ProtocolVersion.*;\n\npublic final class UdpDiscoveryProtocol\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UdpDiscoveryProtocol.class);\n\n\tprivate static final int MAGIC_HEADER_OLD = 0x524e3655; // RN6U\n\tprivate static final int MAGIC_HEADER_VERSIONED = 0x534f3756; // SO7V\n\n\tprivate UdpDiscoveryProtocol()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static UdpDiscoveryPeer parsePacket(ByteBuffer buffer, InetSocketAddress peerAddress)\n\t{\n\t\tif (buffer.limit() < 29)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Buffer is too small: \" + buffer.limit());\n\t\t}\n\n\t\tvar magicHeader = buffer.getInt();\n\t\tProtocolVersion protocolVersion;\n\n\t\tswitch (magicHeader)\n\t\t{\n\t\t\tcase MAGIC_HEADER_OLD ->\n\t\t\t{\n\t\t\t\tbuffer.get(); // reserved\n\n\t\t\t\tprotocolVersion = VERSION_0;\n\t\t\t}\n\t\t\tcase MAGIC_HEADER_VERSIONED ->\n\t\t\t{\n\t\t\t\tvar versionNum = buffer.get();\n\t\t\t\tif (versionNum > VERSION_2.ordinal())\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Unsupported protocol version: {}\", versionNum);\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tprotocolVersion = ProtocolVersion.values()[versionNum];\n\t\t\t}\n\t\t\tdefault ->\n\t\t\t{\n\t\t\t\tlog.warn(\"Unsupported magic header: {}\", magicHeader);\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}\n\t\tbuffer.get(); // reserved\n\t\tbuffer.get(); // reserved\n\t\tbuffer.get(); // reserved\n\n\t\tvar peer = new UdpDiscoveryPeer();\n\t\tpeer.setIpAddress(peerAddress.getAddress().getHostAddress());\n\t\tvar packetStatusNum = buffer.get();\n\t\tif (packetStatusNum > Status.LEAVING.ordinal())\n\t\t{\n\t\t\tlog.warn(\"Unknown packet status: {}\", packetStatusNum);\n\t\t\treturn null;\n\t\t}\n\t\tpeer.setStatus(Status.values()[packetStatusNum]);\n\t\tpeer.setAppId(buffer.getInt());\n\t\tpeer.setPeerId(buffer.getInt());\n\n\t\tif (protocolVersion == VERSION_0)\n\t\t{\n\t\t\tpeer.setPacketIndex(buffer.getInt());\n\t\t\tbuffer.get(); // packet index reset \"overflow\" flag\n\t\t}\n\t\telse\n\t\t{\n\t\t\tpeer.setPacketIndex(buffer.getLong());\n\t\t}\n\n\t\tint userDataSize = buffer.getShort();\n\t\tif (protocolVersion == VERSION_0)\n\t\t{\n\t\t\tbuffer.getShort(); // padding size\n\t\t}\n\t\tif (userDataSize > buffer.remaining())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Userdata size of \" + userDataSize + \" is too big (\" + buffer.remaining() + \" remaining)\");\n\t\t}\n\n\t\tvar buf = Unpooled.wrappedBuffer(buffer);\n\t\tif (protocolVersion != VERSION_2)\n\t\t{\n\t\t\tpeer.setFingerprint((ProfileFingerprint) Serializer.deserializeIdentifierWithSize(buf, ProfileFingerprint.class, ProfileFingerprint.V4_LENGTH));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar fingerPrintSize = buffer.get();\n\t\t\tswitch (fingerPrintSize)\n\t\t\t{\n\t\t\t\tcase ProfileFingerprint.V4_LENGTH -> peer.setFingerprint((ProfileFingerprint) Serializer.deserializeIdentifierWithSize(buf, ProfileFingerprint.class, ProfileFingerprint.V4_LENGTH));\n\t\t\t\tcase ProfileFingerprint.LENGTH -> peer.setFingerprint((ProfileFingerprint) Serializer.deserializeIdentifierWithSize(buf, ProfileFingerprint.class, ProfileFingerprint.LENGTH));\n\t\t\t\tdefault -> throw new IllegalArgumentException(\"Unknown fingerprint size:\" + fingerPrintSize);\n\t\t\t}\n\t\t}\n\t\tpeer.setLocationIdentifier((LocationIdentifier) Serializer.deserializeIdentifier(buf, LocationIdentifier.class));\n\t\tpeer.setLocalPort(Serializer.deserializeShort(buf));\n\t\tpeer.setProfileName(Serializer.deserializeString(buf));\n\n\t\treturn peer;\n\t}\n\n\tpublic static ByteBuffer createPacket(int maxSize, Status status, int appId, int peerId, int counter, ProfileFingerprint fingerprint, LocationIdentifier locationIdentifier, int localPort, String profileName)\n\t{\n\t\tvar buffer = ByteBuffer.allocate(maxSize);\n\n\t\tbuffer.putInt(MAGIC_HEADER_VERSIONED);\n\t\tif (fingerprint.getLength() == ProfileFingerprint.LENGTH)\n\t\t{\n\t\t\tbuffer.put((byte) VERSION_2.ordinal()); // protocol version\n\t\t}\n\t\telse if (fingerprint.getLength() == ProfileFingerprint.V4_LENGTH)\n\t\t{\n\t\t\tbuffer.put((byte) VERSION_1.ordinal()); // protocol version\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unknown fingerprint size:\" + fingerprint.getLength());\n\t\t}\n\t\tbuffer.put((byte) 0);\n\t\tbuffer.put((byte) 0);\n\t\tbuffer.put((byte) 0);\n\n\t\tbuffer.put((byte) status.ordinal());\n\t\tbuffer.putInt(appId);\n\t\tbuffer.putInt(peerId);\n\n\t\tbuffer.putLong(counter);\n\n\t\tvar buf = Unpooled.buffer();\n\t\tSerializer.serialize(buf, fingerprint, ProfileFingerprint.class);\n\t\tSerializer.serialize(buf, locationIdentifier, LocationIdentifier.class);\n\t\tSerializer.serialize(buf, (short) localPort);\n\t\tSerializer.serialize(buf, profileName);\n\n\t\tbuffer.putShort((short) buf.writerIndex());\n\t\tbuffer.put(buf.nioBuffer());\n\t\tbuf.release();\n\n\t\treturn buffer;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/dht/DHTSpringLog.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.dht;\n\nimport lbms.plugins.mldht.kad.DHT.LogLevel;\nimport lbms.plugins.mldht.kad.DHTLogger;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class DHTSpringLog implements DHTLogger\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DHTSpringLog.class);\n\n\tprivate static final String EXCEPTION_HEADING = \"Exception : \";\n\n\t@Override\n\tpublic void log(String s, LogLevel logLevel)\n\t{\n\t\tswitch (logLevel)\n\t\t{\n\t\t\tcase Fatal -> log.error(s);\n\t\t\tcase Error -> log.warn(s);\n\t\t\tcase Info -> log.info(s);\n\t\t\tcase Debug -> log.debug(s);\n\t\t\tcase Verbose -> log.trace(s);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void log(Throwable throwable, LogLevel logLevel)\n\t{\n\t\tswitch (logLevel)\n\t\t{\n\t\t\tcase Fatal -> log.error(EXCEPTION_HEADING, throwable);\n\t\t\tcase Error -> log.warn(EXCEPTION_HEADING, throwable);\n\t\t\tcase Info -> log.info(EXCEPTION_HEADING, throwable);\n\t\t\tcase Debug -> log.debug(EXCEPTION_HEADING, throwable);\n\t\t\tcase Verbose -> log.trace(EXCEPTION_HEADING, throwable);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/dht/DhtService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.dht;\n\nimport io.xeres.app.application.events.DhtNodeFoundEvent;\nimport io.xeres.app.configuration.DataDirConfiguration;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.HostPort;\nimport io.xeres.common.protocol.ip.IP;\nimport io.xeres.common.rest.notification.status.DhtInfo;\nimport io.xeres.common.rest.notification.status.DhtStatus;\nimport io.xeres.common.util.ByteUnitUtils;\nimport lbms.plugins.mldht.DHTConfiguration;\nimport lbms.plugins.mldht.kad.*;\nimport lbms.plugins.mldht.kad.messages.MessageBase;\nimport lbms.plugins.mldht.kad.tasks.NodeLookup;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.net.InetAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.function.Predicate;\n\nimport static lbms.plugins.mldht.kad.DHT.DHTtype.IPV4_DHT;\nimport static lbms.plugins.mldht.kad.DHT.LogLevel.Fatal;\n\n@Service\npublic class DhtService implements DHTStatusListener, DHTConfiguration, DHTStatsListener, DHT.IncomingMessageListener\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DhtService.class);\n\n\tprivate static final String DHT_DATA_DIR = \"dht\";\n\n\t// That file name must not be changed as it's what mldht uses internally\n\tprivate static final String DHT_FILE_NAME = \"baseID.config\";\n\tprivate static final Duration STATS_DELAY = Duration.ofMinutes(1);\n\n\tprivate DHT dht;\n\n\tprivate LocationIdentifier locationIdentifier;\n\tprivate int localPort;\n\n\tprivate Instant lastStats;\n\n\tprivate final Map<Key, LocationIdentifier> searchedKeys = new ConcurrentHashMap<>();\n\n\tprivate final AtomicBoolean isReady = new AtomicBoolean();\n\n\tprivate final DataDirConfiguration dataDirConfiguration;\n\tprivate final ApplicationEventPublisher publisher;\n\tprivate final StatusNotificationService statusNotificationService;\n\n\tpublic DhtService(DataDirConfiguration dataDirConfiguration, ApplicationEventPublisher publisher, StatusNotificationService statusNotificationService)\n\t{\n\t\tthis.dataDirConfiguration = dataDirConfiguration;\n\t\tthis.publisher = publisher;\n\t\tthis.statusNotificationService = statusNotificationService;\n\t}\n\n\tpublic void start(LocationIdentifier locationIdentifier, int localPort)\n\t{\n\t\tif (dht != null && dht.isRunning())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tthis.locationIdentifier = locationIdentifier;\n\t\tthis.localPort = localPort;\n\n\t\tDHT.setLogger(new DHTSpringLog());\n\t\tDHT.setLogLevel(Fatal);\n\t\tdht = new DHT(IPV4_DHT);\n\t\tdht.addStatusListener(this);\n\t\tdht.addStatsListener(this);\n\t\tdht.addIncomingMessageListener(this);\n\t\tlastStats = Instant.now();\n\n\t\ttry\n\t\t{\n\t\t\tdht.start(this);\n\t\t\tdht.bootstrap();\n\t\t\tif (dht.getNode().getNumEntriesInRoutingTable() < 10)\n\t\t\t{\n\t\t\t\taddBootstrappingNodes(); // help the bootstrapping process, in case nothing resolves\n\t\t\t}\n\t\t\tdht.getServerManager().awaitActiveServer().get();\n\t\t}\n\t\tcatch (IOException | ExecutionException | InterruptedException | IllegalStateException e)\n\t\t{\n\t\t\tlog.error(\"Error while setting up DHT: {}\", e.getMessage());\n\t\t\tdht.stop();\n\t\t\tif (e instanceof InterruptedException)\n\t\t\t{\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void stop()\n\t{\n\t\tif (dht != null && dht.isRunning())\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tdht.stop();\n\t\t\t}\n\t\t\tcatch (RuntimeException e)\n\t\t\t{\n\t\t\t\t// Sometimes DHT fails to shut down cleanly, but\n\t\t\t\t// it shouldn't disrupt the rest of the shutdown\n\t\t\t\t// process.\n\t\t\t\tlog.error(\"DHT error: {}\", e.getMessage(), e);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void search(LocationIdentifier locationIdentifier)\n\t{\n\t\tif (dht == null || !dht.isRunning())\n\t\t{\n\t\t\tlog.warn(\"Search is not available yet, DHT is not ready\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar key = new Key(NodeId.create(locationIdentifier));\n\t\tlog.debug(\"Searching LocationIdentifier {} -> node id: {}\", locationIdentifier, key);\n\t\tsearchedKeys.put(key, locationIdentifier);\n\n\t\tvar rpcServer = dht.getServerManager().getRandomActiveServer(false);\n\t\tif (rpcServer == null)\n\t\t{\n\t\t\tlog.debug(\"No RPC server, cannot perform DHT search\");\n\t\t\treturn;\n\t\t}\n\t\tvar nodeLookupTask = new NodeLookup(key, rpcServer, dht.getNode(), false);\n\t\tnodeLookupTask.setInfo(locationIdentifier.toString());\n\t\tnodeLookupTask.addListener(task -> log.debug(\"Task finished: {}\", task.getInfo()));\n\t\tdht.getTaskManager().addTask(nodeLookupTask);\n\t}\n\n\t@Override\n\tpublic void statusChanged(DHTStatus newStatus, DHTStatus oldStatus)\n\t{\n\t\tswitch (newStatus)\n\t\t{\n\t\t\tcase Running ->\n\t\t\t{\n\t\t\t\tlog.info(\"DHT status -> running\");\n\t\t\t\tisReady.set(true);\n\t\t\t\tstatusNotificationService.setDhtInfo(DhtInfo.fromStatus(DhtStatus.RUNNING));\n\t\t\t}\n\n\t\t\tcase Stopped ->\n\t\t\t{\n\t\t\t\tlog.info(\"DHT status -> stopped\");\n\t\t\t\tisReady.set(false);\n\t\t\t\tstatusNotificationService.setDhtInfo(DhtInfo.fromStatus(DhtStatus.OFF));\n\t\t\t}\n\n\t\t\tcase Initializing ->\n\t\t\t{\n\t\t\t\tlog.info(\"DHT status -> initializing\");\n\t\t\t\tisReady.set(false);\n\t\t\t\tstatusNotificationService.setDhtInfo(DhtInfo.fromStatus(DhtStatus.INITIALIZING));\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean isPersistingID()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic Path getStoragePath()\n\t{\n\t\tvar directoryPath = Path.of(dataDirConfiguration.getDataDir(), DHT_DATA_DIR);\n\t\tvar filePath = directoryPath.resolve(DHT_FILE_NAME);\n\n\t\tif (Files.notExists(directoryPath) || Files.notExists(filePath))\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tFiles.createDirectory(directoryPath);\n\n\t\t\t\tvar nodeId = Id.toString(NodeId.create(locationIdentifier)).toUpperCase(Locale.ROOT);\n\t\t\t\tlog.debug(\"Storing own NodeID: {}\", nodeId);\n\n\t\t\t\tFiles.createFile(filePath);\n\t\t\t\tFiles.write(filePath, Collections.singleton(nodeId), StandardCharsets.ISO_8859_1);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Failed to create DHT data storage: \" + e.getMessage(), e);\n\t\t\t}\n\t\t}\n\t\treturn directoryPath;\n\t}\n\n\t@Override\n\tpublic int getListeningPort()\n\t{\n\t\treturn localPort;\n\t}\n\n\t@Override\n\tpublic boolean noRouterBootstrap()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic boolean allowMultiHoming()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic Predicate<InetAddress> filterBindAddress()\n\t{\n\t\treturn address -> IP.isRoutableIp(address.getHostAddress());\n\t}\n\n\t@Override\n\tpublic void statsUpdated(DHTStats dhtStats)\n\t{\n\t\tvar now = Instant.now();\n\n\t\tif (Duration.between(lastStats, now).compareTo(STATS_DELAY) > 0)\n\t\t{\n\t\t\ttraceDhtStats(dhtStats);\n\n\t\t\tif (dht.getStatus() == DHTStatus.Running)\n\t\t\t{\n\t\t\t\tstatusNotificationService.setDhtInfo(DhtInfo.fromStats(\n\t\t\t\t\t\tdhtStats.getNumPeers(),\n\t\t\t\t\t\tdhtStats.getNumReceivedPackets(),\n\t\t\t\t\t\tdhtStats.getRpcStats().getReceivedBytes(),\n\t\t\t\t\t\tdhtStats.getNumSentPackets(),\n\t\t\t\t\t\tdhtStats.getRpcStats().getSentBytes(),\n\t\t\t\t\t\tdhtStats.getDbStats().getKeyCount(),\n\t\t\t\t\t\tdhtStats.getDbStats().getItemCount()));\n\t\t\t}\n\t\t\tlastStats = now;\n\t\t}\n\t}\n\n\t@Override\n\tpublic void received(DHT dht, MessageBase messageBase)\n\t{\n\t\tif (messageBase.getType() == MessageBase.Type.RSP_MSG && messageBase.getMethod() == MessageBase.Method.FIND_NODE)\n\t\t{\n\t\t\tvar foundLocationIdentifier = searchedKeys.remove(messageBase.getID());\n\t\t\tif (foundLocationIdentifier != null)\n\t\t\t{\n\t\t\t\tlog.debug(\"Found node for id {}, IP: {}\", foundLocationIdentifier, messageBase.getOrigin());\n\t\t\t\tpublisher.publishEvent(new DhtNodeFoundEvent(foundLocationIdentifier, new HostPort(messageBase.getOrigin().getAddress().getHostAddress(), messageBase.getOrigin().getPort())));\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic boolean isReady()\n\t{\n\t\treturn isReady.get();\n\t}\n\n\tprivate void addBootstrappingNodes()\n\t{\n\t\tvar line = \"\";\n\n\t\ttry (var reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(DhtService.class.getResourceAsStream(\"/bdboot.txt\")))))\n\t\t{\n\t\t\twhile (reader.ready())\n\t\t\t{\n\t\t\t\tline = reader.readLine();\n\t\t\t\tvar tokens = line.split(\" \");\n\t\t\t\tvar ip = tokens[0];\n\t\t\t\tvar port = Integer.parseInt(tokens[1]);\n\n\t\t\t\tif (!IP.isRoutableIp(ip))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"IP is invalid\");\n\t\t\t\t}\n\t\t\t\tif (IP.isInvalidPort(port))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"Port is invalid\");\n\t\t\t\t}\n\t\t\t\tlog.debug(\"adding node {}:{}\", ip, port);\n\t\t\t\tdht.addDHTNode(ip, port);\n\t\t\t}\n\t\t}\n\t\tcatch (IOException | IllegalArgumentException e)\n\t\t{\n\t\t\tlog.warn(\"Couldn't parse ip<space>port of line: {} ({})\", line, e.getMessage());\n\t\t}\n\t}\n\n\tprivate static void traceDhtStats(DHTStats dhtStats)\n\t{\n\t\tif (log.isTraceEnabled())\n\t\t{\n\t\t\tlog.debug(\"Peers: {}, recv pkt: {} ({}), sent pkt: {} ({}), keys: {}, items: {}\",\n\t\t\t\t\tdhtStats.getNumPeers(),\n\t\t\t\t\tdhtStats.getNumReceivedPackets(),\n\t\t\t\t\tByteUnitUtils.fromBytes(dhtStats.getRpcStats().getReceivedBytes()),\n\t\t\t\t\tdhtStats.getNumSentPackets(),\n\t\t\t\t\tByteUnitUtils.fromBytes(dhtStats.getRpcStats().getSentBytes()),\n\t\t\t\t\tdhtStats.getDbStats().getKeyCount(),\n\t\t\t\t\tdhtStats.getDbStats().getItemCount());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/dht/NodeId.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.dht;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.common.id.LocationIdentifier;\n\nimport java.nio.charset.StandardCharsets;\n\nfinal class NodeId\n{\n\tprivate static final String VERSION = \"RS_VERSION_0.5.1\\0\"; // null terminator is included\n\n\tprivate NodeId()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic byte[] create(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar md = new Sha1MessageDigest();\n\t\tvar version = VERSION.getBytes(StandardCharsets.US_ASCII);\n\t\tmd.update(version);\n\t\tmd.update(locationIdentifier.getBytes());\n\t\treturn md.getBytes();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/dht/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * DHT implementation. Note:\n * <ul>\n *     <li>RS uses the DHT of Bittorrent</li>\n *     <li>it has some limitations regarding what can be put in the metadata</li>\n *     <li>they want to switch to a better one at some point</li>\n * </ul>\n */\npackage io.xeres.app.net.dht;\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/external/ExternalIpResolver.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.external;\n\nimport io.xeres.common.protocol.dns.DNS;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.net.InetAddress;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Map;\n\n/**\n * A service to find the external IP address. Currently, uses the DNS protocol.\n * <p>Note: this is only used if UPNPService fails to find it itself.\n */\n@Service\npublic class ExternalIpResolver\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ExternalIpResolver.class);\n\n\tprivate static final String OPENDNS_OWN_IP_HOST = \"myip.opendns.com\";\n\tprivate static final String AKAMAI_OWN_IP_HOST = \"whoami.akamai.net\";\n\n\tprivate static final Map<String, String> RESOLVERS = Map.of(\n\t\t\t\"208.67.222.222\", OPENDNS_OWN_IP_HOST, // resolver1.opendns.com\n\t\t\t\"208.67.220.220\", OPENDNS_OWN_IP_HOST, // resolver2.opendns.com\n\t\t\t\"208.67.222.220\", OPENDNS_OWN_IP_HOST, // resolver3.opendns.com\n\t\t\t\"208.67.220.222\", OPENDNS_OWN_IP_HOST, // resolver4.opendns.com\n\t\t\t\"193.108.88.1\", AKAMAI_OWN_IP_HOST // ns1-1.akamaitech.net\n\t);\n\n\t/**\n\t * Finds the external IP address.\n\t *\n\t * @return the IP address or null if not found\n\t */\n\tpublic String find()\n\t{\n\t\treturn findExternalIpAddressUsingDns();\n\t}\n\n\tprivate String findExternalIpAddressUsingDns()\n\t{\n\t\tvar keys = new ArrayList<>(RESOLVERS.keySet());\n\t\tCollections.shuffle(keys);\n\n\t\tInetAddress externalIpAddress = null;\n\n\t\tfor (String nameServer : keys)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\texternalIpAddress = DNS.resolve(RESOLVERS.get(nameServer), nameServer);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\t// Log the error and try the next server\n\t\t\t\tlog.error(\"Failed to resolve own IP using server {}: {}\", nameServer, e.getMessage());\n\t\t\t}\n\t\t}\n\n\t\tif (externalIpAddress == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn externalIpAddress.getHostAddress();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/ConnectionType.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\npublic enum ConnectionType\n{\n\tTCP_INCOMING(\"incoming\"),\n\tTCP_OUTGOING(\"outgoing\"),\n\tTOR_OUTGOING(\"Tor\"),\n\tI2P_OUTGOING(\"I2P\");\n\n\tprivate final String description;\n\n\tConnectionType(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/DefaultItemFuture.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.util.concurrent.Future;\nimport io.netty.util.concurrent.GenericFutureListener;\n\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\n\nclass DefaultItemFuture implements ItemFuture\n{\n\tprivate final Future<Void> future;\n\tprivate final int size;\n\n\n\tpublic DefaultItemFuture(Future<Void> future, int size)\n\t{\n\t\tthis.future = future;\n\t\tthis.size = size;\n\t}\n\n\tpublic DefaultItemFuture(Future<Void> future)\n\t{\n\t\tthis.future = future;\n\t\tsize = 0;\n\t}\n\n\t@Override\n\tpublic int getSize()\n\t{\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic boolean isSuccess()\n\t{\n\t\treturn future.isSuccess();\n\t}\n\n\t@Override\n\tpublic boolean isCancellable()\n\t{\n\t\treturn future.isCancellable();\n\t}\n\n\t@Override\n\tpublic Throwable cause()\n\t{\n\t\treturn future.cause();\n\t}\n\n\t@Override\n\tpublic Future<Void> addListener(GenericFutureListener<? extends Future<? super Void>> genericFutureListener)\n\t{\n\t\treturn future.addListener(genericFutureListener);\n\t}\n\n\t@SafeVarargs\n\t@Override\n\tpublic final Future<Void> addListeners(GenericFutureListener<? extends Future<? super Void>>... genericFutureListeners)\n\t{\n\t\treturn future.addListeners(genericFutureListeners);\n\t}\n\n\t@Override\n\tpublic Future<Void> removeListener(GenericFutureListener<? extends Future<? super Void>> genericFutureListener)\n\t{\n\t\treturn future.removeListener(genericFutureListener);\n\t}\n\n\t@SafeVarargs\n\t@Override\n\tpublic final Future<Void> removeListeners(GenericFutureListener<? extends Future<? super Void>>... genericFutureListeners)\n\t{\n\t\treturn future.removeListeners(genericFutureListeners);\n\t}\n\n\t@Override\n\tpublic Future<Void> sync() throws InterruptedException\n\t{\n\t\treturn future.sync();\n\t}\n\n\t@Override\n\tpublic Future<Void> syncUninterruptibly()\n\t{\n\t\treturn future.syncUninterruptibly();\n\t}\n\n\t@Override\n\tpublic Future<Void> await() throws InterruptedException\n\t{\n\t\treturn future.await();\n\t}\n\n\t@Override\n\tpublic Future<Void> awaitUninterruptibly()\n\t{\n\t\treturn future.awaitUninterruptibly();\n\t}\n\n\t@Override\n\tpublic boolean await(long l, TimeUnit timeUnit) throws InterruptedException\n\t{\n\t\treturn future.await(l, timeUnit);\n\t}\n\n\t@Override\n\tpublic boolean await(long l) throws InterruptedException\n\t{\n\t\treturn future.await(l);\n\t}\n\n\t@Override\n\tpublic boolean awaitUninterruptibly(long l, TimeUnit timeUnit)\n\t{\n\t\treturn future.awaitUninterruptibly(l, timeUnit);\n\t}\n\n\t@Override\n\tpublic boolean awaitUninterruptibly(long l)\n\t{\n\t\treturn future.awaitUninterruptibly(l);\n\t}\n\n\t@Override\n\tpublic Void getNow()\n\t{\n\t\treturn future.getNow();\n\t}\n\n\t@Override\n\tpublic boolean cancel(boolean b)\n\t{\n\t\treturn future.cancel(b);\n\t}\n\n\t@Override\n\tpublic boolean isCancelled()\n\t{\n\t\treturn future.isCancelled();\n\t}\n\n\t@Override\n\tpublic boolean isDone()\n\t{\n\t\treturn future.isDone();\n\t}\n\n\t@Override\n\tpublic Void get() throws InterruptedException, ExecutionException\n\t{\n\t\treturn future.get();\n\t}\n\n\t@Override\n\tpublic Void get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException\n\t{\n\t\treturn future.get(timeout, unit);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/ItemFuture.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.util.concurrent.Future;\n\npublic interface ItemFuture extends Future<Void>\n{\n\t/**\n\t * Gets the size of the item in its serialized form.\n\t *\n\t * @return the size of the item\n\t */\n\tint getSize();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/PeerAttribute.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.util.AttributeKey;\n\npublic final class PeerAttribute\n{\n\tpublic static final AttributeKey<Boolean> MULTI_PACKET = AttributeKey.valueOf(\"MULTI_PACKET\");\n\tpublic static final AttributeKey<PeerConnection> PEER_CONNECTION = AttributeKey.valueOf(\"PEER_CONNECTION\");\n\n\tprivate PeerAttribute()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/PeerConnection.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.util.concurrent.ScheduledFuture;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.common.util.NoSuppressedRunnable;\n\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.LongAdder;\n\npublic class PeerConnection\n{\n\t/**\n\t * Gxs transaction ID (int). Must be incremented and unique for each new transaction.\n\t */\n\tpublic static final int KEY_GXS_TRANSACTION_ID = 1;\n\n\t/**\n\t * The bandwidth advertised by the peer (long), in bytes/seconds.\n\t */\n\tpublic static final int KEY_BANDWIDTH = 2;\n\n\tprivate Location location;\n\tprivate final ChannelHandlerContext ctx;\n\tprivate final Set<RsService> services = new HashSet<>();\n\tprivate final AtomicBoolean servicesSent = new AtomicBoolean(false);\n\tprivate final Map<Integer, Object> peerData = new HashMap<>();\n\tprivate final Map<Integer, Map<Integer, Object>> serviceData = new HashMap<>();\n\tprivate final List<ScheduledFuture<?>> schedules = new ArrayList<>();\n\tprivate final LongAdder sent = new LongAdder();\n\tprivate final LongAdder received = new LongAdder();\n\n\tpublic PeerConnection(Location location, ChannelHandlerContext ctx)\n\t{\n\t\tthis.location = location;\n\t\tthis.ctx = ctx;\n\t}\n\n\tpublic ChannelHandlerContext getCtx()\n\t{\n\t\treturn ctx;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void updateLocation(Location location)\n\t{\n\t\tif (this.location.equals(location)) // Only update, don't change for another\n\t\t{\n\t\t\tthis.location = location;\n\t\t}\n\t}\n\n\tpublic void addService(RsService service)\n\t{\n\t\tservices.add(service);\n\t}\n\n\tpublic boolean isServiceSupported(RsService rsService)\n\t{\n\t\treturn services.contains(rsService);\n\t}\n\n\tpublic boolean isServiceSupported(int serviceId)\n\t{\n\t\treturn services.stream()\n\t\t\t\t.anyMatch(rsService -> rsService.getServiceType().getType() == serviceId);\n\t}\n\n\tpublic boolean canSendServices()\n\t{\n\t\treturn servicesSent.compareAndSet(false, true);\n\t}\n\n\t/**\n\t * Puts data into a peer.\n\t *\n\t * @param key  the key\n\t * @param data the data\n\t */\n\tpublic void putPeerData(int key, Object data)\n\t{\n\t\tpeerData.put(key, data);\n\t}\n\n\t/**\n\t * Gets data from a peer.\n\t *\n\t * @param key the key\n\t * @return the data\n\t */\n\tpublic Optional<Object> getPeerData(int key)\n\t{\n\t\treturn Optional.ofNullable(peerData.get(key));\n\t}\n\n\t/**\n\t * Removes data from a peer.\n\t *\n\t * @param key the key\n\t */\n\tpublic void removePeerData(int key)\n\t{\n\t\tpeerData.remove(key);\n\t}\n\n\t/**\n\t * Adds data specific to a service.\n\t *\n\t * @param service the service to add data to\n\t * @param key     the key\n\t * @param data    the data\n\t */\n\tpublic void putServiceData(RsService service, int key, Object data)\n\t{\n\t\tserviceData.computeIfAbsent(service.getServiceType().getType(), _ -> new HashMap<>()).put(key, data);\n\t}\n\n\t/**\n\t * Gets data specific to a service.\n\t *\n\t * @param service the service to get data from\n\t * @param key the key\n\t * @return the data or an empty optional if there was none\n\t */\n\tpublic Optional<Object> getServiceData(RsService service, int key)\n\t{\n\t\tvar serviceMap = serviceData.get(service.getServiceType().getType());\n\t\tif (serviceMap == null)\n\t\t{\n\t\t\treturn Optional.empty();\n\t\t}\n\t\treturn Optional.ofNullable(serviceMap.get(key));\n\t}\n\n\t/**\n\t * Removes data associated with the service.\n\t *\n\t * @param service the service to remove data from\n\t * @param key the key\n\t */\n\tpublic void removeServiceData(RsService service, int key)\n\t{\n\t\tvar serviceMap = serviceData.get(service.getServiceType().getType());\n\t\tif (serviceMap != null)\n\t\t{\n\t\t\tserviceMap.remove(key);\n\t\t}\n\t}\n\n\tpublic void scheduleAtFixedRate(NoSuppressedRunnable command, long initialDelay, long period, TimeUnit unit)\n\t{\n\t\t@SuppressWarnings(\"resource\") var scheduledFuture = ctx.executor().scheduleAtFixedRate(command, initialDelay, period, unit);\n\t\tschedules.add(scheduledFuture);\n\t}\n\n\tpublic void scheduleWithFixedDelay(NoSuppressedRunnable command, long initialDelay, long delay, TimeUnit unit)\n\t{\n\t\t@SuppressWarnings(\"resource\") var scheduledFuture = ctx.executor().scheduleWithFixedDelay(command, initialDelay, delay, unit);\n\t\tschedules.add(scheduledFuture);\n\t}\n\n\t/**\n\t * Schedules a one-shot command that becomes active after a defined delay.\n\t *\n\t * @param command the command to execute\n\t * @param delay the delay after which to execute the command\n\t * @param unit the unit of the delay\n\t */\n\tpublic void schedule(NoSuppressedRunnable command, long delay, TimeUnit unit)\n\t{\n\t\t@SuppressWarnings(\"resource\") var scheduledFuture = ctx.executor().schedule(command, delay, unit);\n\t\tschedules.add(scheduledFuture);\n\t}\n\n\tpublic void shutdown()\n\t{\n\t\tservices.forEach(rsService -> rsService.shutdown(this));\n\t}\n\n\tpublic void cleanup()\n\t{\n\t\tschedules.forEach(scheduledFuture -> scheduledFuture.cancel(false));\n\t}\n\n\tpublic void incrementSentCounter(long value)\n\t{\n\t\tsent.add(value);\n\t}\n\n\tpublic void incrementReceivedCounter(long value)\n\t{\n\t\treceived.add(value);\n\t}\n\n\tpublic long getSentCounter()\n\t{\n\t\treturn sent.longValue();\n\t}\n\n\tpublic long getReceivedCounter()\n\t{\n\t\treturn received.longValue();\n\t}\n\n\tpublic long getMaximumBandwidth()\n\t{\n\t\treturn (long) getPeerData(KEY_BANDWIDTH).orElse(0L);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn location +\n\t\t\t\t\"@\" + (ctx != null ? ctx.channel().remoteAddress() : \"<unknown>\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/PeerConnectionManager.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.util.concurrent.FailedFuture;\nimport io.xeres.app.application.events.PeerConnectedEvent;\nimport io.xeres.app.application.events.PeerDisconnectedEvent;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.service.notification.availability.AvailabilityNotificationService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.sliceprobe.item.SliceProbeItem;\nimport io.xeres.common.location.Availability;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Component;\n\nimport java.util.EnumSet;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.function.Consumer;\n\nimport static io.xeres.app.net.peer.PeerAttribute.PEER_CONNECTION;\n\n/**\n * This component manages connected peers (addition, removals, writing data to them, etc...)\n */\n@Component\npublic class PeerConnectionManager\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(PeerConnectionManager.class);\n\n\tprivate final StatusNotificationService statusNotificationService;\n\tprivate final AvailabilityNotificationService availabilityNotificationService;\n\tprivate final ApplicationEventPublisher publisher;\n\n\tprivate final Map<Long, PeerConnection> peers = new ConcurrentHashMap<>();\n\n\tPeerConnectionManager(StatusNotificationService statusNotificationService, AvailabilityNotificationService availabilityNotificationService, ApplicationEventPublisher publisher)\n\t{\n\t\tthis.statusNotificationService = statusNotificationService;\n\t\tthis.availabilityNotificationService = availabilityNotificationService;\n\t\tthis.publisher = publisher;\n\t}\n\n\t/**\n\t * Adds a connected peer.\n\t *\n\t * @param location the location of the peer\n\t * @param ctx      the context\n\t * @return a peer connection\n\t */\n\tpublic PeerConnection addPeer(Location location, ChannelHandlerContext ctx)\n\t{\n\t\tvar peerConnection = new PeerConnection(location, ctx);\n\t\tif (peers.put(location.getId(), peerConnection) != null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Location \" + location + \" added already\");\n\t\t}\n\t\tctx.channel().attr(PEER_CONNECTION).set(peerConnection);\n\t\tavailabilityNotificationService.changeAvailability(location, Availability.AVAILABLE);\n\t\tupdateCurrentUsersCount();\n\t\tpublisher.publishEvent(new PeerConnectedEvent(location.getLocationIdentifier()));\n\t\treturn peerConnection;\n\t}\n\n\t/**\n\t * Removes a peer because it disconnected.\n\t *\n\t * @param location the location of the peer\n\t */\n\tpublic void removePeer(Location location)\n\t{\n\t\tif (peers.remove(location.getId()) == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Location \" + location + \" is not in the list of peers\");\n\t\t}\n\t\tavailabilityNotificationService.changeAvailability(location, Availability.OFFLINE);\n\t\tupdateCurrentUsersCount();\n\t\tpublisher.publishEvent(new PeerDisconnectedEvent(location.getId(), location.getLocationIdentifier()));\n\t}\n\n\t/**\n\t * Gets a peer by its location id\n\t *\n\t * @param id the id of the location\n\t * @return the peer connection\n\t */\n\tpublic PeerConnection getPeerByLocation(long id)\n\t{\n\t\treturn peers.get(id);\n\t}\n\n\t/**\n\t * Gets a random peer.\n\t *\n\t * @return a random peer\n\t */\n\tpublic synchronized PeerConnection getRandomPeer()\n\t{\n\t\tvar size = peers.size();\n\t\treturn peers.values().stream()\n\t\t\t\t.skip(size > 0 ? ThreadLocalRandom.current().nextInt(size) : 0)\n\t\t\t\t.findFirst().orElse(null);\n\t}\n\n\tpublic void shutdown()\n\t{\n\t\tpeers.forEach((_, peerConnection) -> peerConnection.shutdown());\n\t\tavailabilityNotificationService.shutdown();\n\t}\n\n\t/**\n\t * Writes an item to a location.\n\t *\n\t * @param location  the target location\n\t * @param item      the item to write\n\t * @param rsService the service concerned\n\t * @return an ItemFuture containing the item's write state and item serialized state\n\t */\n\tpublic ItemFuture writeItem(Location location, Item item, RsService rsService)\n\t{\n\t\tvar peer = peers.get(location.getId());\n\t\tif (peer != null)\n\t\t{\n\t\t\treturn setOutgoingAndWriteItem(peer, item, rsService);\n\t\t}\n\t\treturn new DefaultItemFuture(new FailedFuture<>(null, new IllegalStateException(\"Peer with connection \" + location + \" not found while trying to write item. User disconnected?\")));\n\t}\n\n\t/**\n\t * Writes an item to a peer.\n\t *\n\t * @param peerConnection the target peer\n\t * @param item           the item to write\n\t * @param rsService      the service concerned\n\t * @return an ItemFuture containing the item's write state and item serialized state\n\t */\n\tpublic ItemFuture writeItem(PeerConnection peerConnection, Item item, RsService rsService)\n\t{\n\t\tvar peer = peers.get(peerConnection.getLocation().getId());\n\t\tif (peer != null)\n\t\t{\n\t\t\treturn setOutgoingAndWriteItem(peer, item, rsService);\n\t\t}\n\t\treturn new DefaultItemFuture(new FailedFuture<>(null, new IllegalStateException(\"Peer with connection \" + peerConnection.getLocation() + \" not found while trying to write item. User disconnected?\")));\n\t}\n\n\t/**\n\t * Executes an action for all peers.\n\t *\n\t * @param action    the action to execute\n\t * @param rsService the service that has to be enabled for the peer as well. Can be null, in that case, all peers are considered for the action regardless of the service they're running\n\t */\n\tpublic void doForAllPeers(Consumer<PeerConnection> action, RsService rsService)\n\t{\n\t\tpeers.forEach((_, peerConnection) ->\n\t\t{\n\t\t\tif (rsService == null || peerConnection.isServiceSupported(rsService))\n\t\t\t{\n\t\t\t\taction.accept(peerConnection);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Executes an action for all peers except the originator.\n\t *\n\t * @param action    the action to execute\n\t * @param sender    the originator of the action\n\t * @param rsService the service that has to be enabled for the peer as well. Can be null; in that case, all peers are considered for the action regardless of the service they're running\n\t */\n\tpublic void doForAllPeersExceptSender(Consumer<PeerConnection> action, PeerConnection sender, RsService rsService)\n\t{\n\t\tpeers.values().stream()\n\t\t\t\t.filter(peerConnection -> !peerConnection.equals(sender))\n\t\t\t\t.filter(peerConnection -> rsService == null || peerConnection.isServiceSupported(rsService))\n\t\t\t\t.forEach(action);\n\t}\n\n\tpublic boolean isServiceSupported(Location location, int serviceId)\n\t{\n\t\tvar peer = peers.get(location.getId());\n\t\tif (peer != null)\n\t\t{\n\t\t\treturn peer.isServiceSupported(serviceId);\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Writes the slice probe item. This is only needed for very particular cases.\n\t *\n\t * @param ctx the context\n\t */\n\tpublic static void writeSliceProbe(ChannelHandlerContext ctx)\n\t{\n\t\tvar item = SliceProbeItem.from(ctx);\n\t\tvar rawItem = item.serializeItem(EnumSet.noneOf(SerializationFlags.class));\n\t\tctx.writeAndFlush(rawItem);\n\t}\n\n\t/**\n\t * Returns the number of connected peers.\n\t *\n\t * @return the number of connected peers\n\t */\n\tpublic int getNumberOfPeers()\n\t{\n\t\treturn peers.size();\n\t}\n\n\tprivate static ItemFuture setOutgoingAndWriteItem(PeerConnection peerConnection, Item item, RsService rsService)\n\t{\n\t\titem.setOutgoing(peerConnection.getCtx().alloc(), rsService);\n\t\treturn writeItem(peerConnection, item);\n\t}\n\n\tprivate static ItemFuture writeItem(PeerConnection peerConnection, Item item)\n\t{\n\t\tvar rawItem = item.serializeItem(EnumSet.noneOf(SerializationFlags.class));\n\t\tvar size = rawItem.getSize(); // get it before it's written\n\t\tlog.debug(\"==> {}\", item);\n\t\tlog.trace(\"Message content: {}\", rawItem);\n\t\tpeerConnection.incrementSentCounter(size);\n\t\treturn new DefaultItemFuture(peerConnection.getCtx().writeAndFlush(rawItem), size);\n\t}\n\n\tprivate void updateCurrentUsersCount()\n\t{\n\t\tstatusNotificationService.setCurrentUsersCount(getNumberOfPeers());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.netty.bootstrap.Bootstrap;\nimport io.netty.channel.EventLoopGroup;\nimport io.netty.channel.MultiThreadIoEventLoopGroup;\nimport io.netty.channel.nio.NioIoHandler;\nimport io.netty.channel.socket.nio.NioSocketChannel;\nimport io.netty.resolver.AddressResolverGroup;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport io.xeres.common.properties.StartupProperties;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.net.SocketAddress;\n\nimport static io.xeres.common.properties.StartupProperties.Property.FAST_SHUTDOWN;\n\nabstract class PeerClient\n{\n\t@SuppressWarnings(\"NonConstantLogger\")\n\tprotected final Logger log = LoggerFactory.getLogger(getClass().getName());\n\n\tprotected final SettingsService settingsService;\n\tprotected final NetworkProperties networkProperties;\n\tprotected final ProfileService profileService;\n\tprotected final LocationService locationService;\n\tprotected final PeerConnectionManager peerConnectionManager;\n\tprotected final DatabaseSessionManager databaseSessionManager;\n\tprotected final ServiceInfoRsService serviceInfoRsService;\n\tprotected final UiBridgeService uiBridgeService;\n\tprotected final RsServiceRegistry rsServiceRegistry;\n\n\tprivate Bootstrap bootstrap;\n\tprivate EventLoopGroup group;\n\n\tpublic abstract PeerInitializer getPeerInitializer();\n\n\tpublic abstract AddressResolverGroup<? extends SocketAddress> getAddressResolverGroup();\n\n\tprotected PeerClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tthis.settingsService = settingsService;\n\t\tthis.networkProperties = networkProperties;\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.serviceInfoRsService = serviceInfoRsService;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t}\n\n\tpublic void start()\n\t{\n\t\tlog.info(\"Starting peer client...\");\n\t\tgroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());\n\n\t\tbootstrap = new Bootstrap();\n\t\tsetAddressResolver();\n\t\tbootstrap.group(group)\n\t\t\t\t.channel(NioSocketChannel.class)\n\t\t\t\t.handler(getPeerInitializer());\n\t}\n\n\tprivate void setAddressResolver()\n\t{\n\t\tvar addressResolverGroup = getAddressResolverGroup();\n\t\tif (addressResolverGroup != null)\n\t\t{\n\t\t\tbootstrap.resolver(addressResolverGroup);\n\t\t}\n\t}\n\n\tpublic void stop()\n\t{\n\t\tif (group == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (StartupProperties.getBoolean(FAST_SHUTDOWN, false))\n\t\t{\n\t\t\tlog.debug(\"Shutting down peer client (fast)...\");\n\t\t\tgroup.shutdownGracefully();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.info(\"Shutting down peer client...\");\n\t\t\ttry\n\t\t\t{\n\t\t\t\tgroup.shutdownGracefully().sync();\n\t\t\t}\n\t\t\tcatch (InterruptedException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Error while shutting down peer client: {}\", e.getMessage());\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void connect(PeerAddress peerAddress)\n\t{\n\t\tif (group != null)\n\t\t{\n\t\t\tbootstrap.connect(peerAddress.getSocketAddress());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerI2pClient.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.netty.resolver.AddressResolverGroup;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport org.springframework.stereotype.Component;\n\nimport java.net.SocketAddress;\n\nimport static io.xeres.app.net.peer.ConnectionType.I2P_OUTGOING;\n\n@Component\npublic class PeerI2pClient extends PeerClient\n{\n\tpublic PeerI2pClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tsuper(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic PeerInitializer getPeerInitializer()\n\t{\n\t\treturn new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, I2P_OUTGOING, profileService, uiBridgeService, rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic AddressResolverGroup<? extends SocketAddress> getAddressResolverGroup()\n\t{\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerInitializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.netty.channel.ChannelHandler;\nimport io.netty.channel.ChannelInitializer;\nimport io.netty.channel.socket.SocketChannel;\nimport io.netty.handler.proxy.Socks5ProxyHandler;\nimport io.netty.handler.ssl.SslContext;\nimport io.netty.handler.timeout.IdleStateHandler;\nimport io.xeres.app.crypto.x509.X509;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.ConnectionType;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.net.peer.pipeline.*;\nimport io.xeres.app.net.peer.ssl.SSL;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\n\nimport javax.net.ssl.SSLException;\nimport java.net.InetSocketAddress;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.cert.CertificateException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.Duration;\n\nimport static io.xeres.app.net.peer.ConnectionType.I2P_OUTGOING;\nimport static io.xeres.app.net.peer.ConnectionType.TOR_OUTGOING;\n\npublic class PeerInitializer extends ChannelInitializer<SocketChannel>\n{\n\tpublic static final Duration PEER_IDLE_TIMEOUT = Duration.ofMinutes(2); /* peers not responding during that time are considered dead */\n\tpublic static final Duration ACTIVITY_PROD = Duration.ofMinutes(1); /* if idle, sends a prod activity after that time */\n\n\tprivate final SslContext sslContext;\n\tprivate final ConnectionType connectionType;\n\tprivate final SettingsService settingsService;\n\tprivate final NetworkProperties networkProperties;\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ServiceInfoRsService serviceInfoRsService;\n\tprivate final UiBridgeService uiBridgeService;\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\n\tprivate static final ChannelHandler SIMPLE_PACKET_ENCODER = new SimplePacketEncoder();\n\tprivate static final ChannelHandler ITEM_ENCODER = new ItemEncoder();\n\tprivate static final ChannelHandler IDLE_EVENT_HANDLER = new IdleEventHandler(PEER_IDLE_TIMEOUT);\n\n\tpublic PeerInitializer(PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, LocationService locationService, SettingsService settingsService, NetworkProperties networkProperties, ServiceInfoRsService serviceInfoRsService, ConnectionType connectionType, ProfileService profileService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tthis.settingsService = settingsService;\n\t\tthis.profileService = profileService;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\ttry\n\t\t{\n\t\t\tsslContext = SSL.createSslContext(settingsService.getLocationPrivateKeyData(), X509.getCertificate(settingsService.getLocationCertificate()), connectionType);\n\t\t}\n\t\tcatch (SSLException | NoSuchAlgorithmException | InvalidKeySpecException | CertificateException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Error setting up PeerClient: \" + e.getMessage(), e);\n\t\t}\n\t\tthis.networkProperties = networkProperties;\n\t\tthis.serviceInfoRsService = serviceInfoRsService;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t\tthis.locationService = locationService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.connectionType = connectionType;\n\t}\n\n\t@Override\n\tprotected void initChannel(SocketChannel channel)\n\t{\n\t\tvar pipeline = channel.pipeline();\n\n\t\t// Build the pipeline in order.\n\t\t// Inbound\n\t\t// vvvvvvv\n\n\t\t// add SOCKS5 connection if Tor or I2P\n\t\tif (connectionType == TOR_OUTGOING && settingsService.hasTorSocksConfigured())\n\t\t{\n\t\t\tvar hostPort = settingsService.getTorSocksHostPort();\n\t\t\tpipeline.addLast(new Socks5ProxyHandler(new InetSocketAddress(hostPort.host(), hostPort.port())));\n\t\t}\n\t\telse if (connectionType == I2P_OUTGOING && settingsService.hasI2pSocksConfigured())\n\t\t{\n\t\t\tvar hostPort = settingsService.getI2pSocksHostPort();\n\t\t\tpipeline.addLast(new Socks5ProxyHandler(new InetSocketAddress(hostPort.host(), hostPort.port())));\n\t\t}\n\n\t\t// add SSL to encrypt and decrypt everything\n\t\tpipeline.addLast(sslContext.newHandler(channel.alloc()));\n\n\t\t// decoder (inbound)\n\t\tpipeline.addLast(new PacketDecoder());\n\t\tpipeline.addLast(new ItemDecoder());\n\n\t\t// encoder (outbound)\n\t\tpipeline.addLast(networkProperties.isPacketSlicing() ? new MultiPacketEncoder() : SIMPLE_PACKET_ENCODER);\n\t\tpipeline.addLast(ITEM_ENCODER);\n\n\t\t// business logic\n\t\tpipeline.addLast(new IdleStateHandler((int) PEER_IDLE_TIMEOUT.toSeconds(), (int) ACTIVITY_PROD.toSeconds(), 0));\n\t\tpipeline.addLast(IDLE_EVENT_HANDLER);\n\n\t\t// ^^^^^^^^\n\t\t// Outbound\n\n\t\tpipeline.addLast(new PeerHandler(profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, connectionType, uiBridgeService, rsServiceRegistry));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerServer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.netty.bootstrap.ServerBootstrap;\nimport io.netty.channel.ChannelFuture;\nimport io.netty.channel.ChannelOption;\nimport io.netty.channel.EventLoopGroup;\nimport io.netty.channel.MultiThreadIoEventLoopGroup;\nimport io.netty.channel.nio.NioIoHandler;\nimport io.netty.channel.socket.nio.NioServerSocketChannel;\nimport io.netty.handler.logging.LogLevel;\nimport io.netty.handler.logging.LoggingHandler;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport io.xeres.common.properties.StartupProperties;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.net.peer.ConnectionType.TCP_INCOMING;\nimport static io.xeres.common.properties.StartupProperties.Property.FAST_SHUTDOWN;\n\n\nabstract class PeerServer\n{\n\t@SuppressWarnings(\"NonConstantLogger\")\n\tprotected final Logger log = LoggerFactory.getLogger(getClass().getName());\n\n\tprivate final SettingsService settingsService;\n\tprivate final NetworkProperties networkProperties;\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ServiceInfoRsService serviceInfoRsService;\n\tprivate final UiBridgeService uiBridgeService;\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\n\tprivate EventLoopGroup bossGroup;\n\tprivate EventLoopGroup workerGroup;\n\tprivate ChannelFuture channel;\n\n\tprotected PeerServer(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tthis.settingsService = settingsService;\n\t\tthis.networkProperties = networkProperties;\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.serviceInfoRsService = serviceInfoRsService;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t}\n\n\tpublic void start(String host, int localPort)\n\t{\n\t\tvar factory = NioIoHandler.newFactory();\n\t\tbossGroup = new MultiThreadIoEventLoopGroup(1, factory);\n\t\tworkerGroup = new MultiThreadIoEventLoopGroup(factory);\n\n\t\ttry\n\t\t{\n\t\t\tvar serverBootstrap = new ServerBootstrap();\n\t\t\tserverBootstrap.group(bossGroup, workerGroup)\n\t\t\t\t\t.channel(NioServerSocketChannel.class)\n\t\t\t\t\t.option(ChannelOption.SO_BACKLOG, 128) // should be more\n\t\t\t\t\t.option(ChannelOption.SO_REUSEADDR, true)\n\t\t\t\t\t.handler(new LoggingHandler(LogLevel.DEBUG))\n\t\t\t\t\t.childHandler(new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, TCP_INCOMING, profileService, uiBridgeService, rsServiceRegistry));\n\n\t\t\tchannel = StringUtils.isBlank(host) ? serverBootstrap.bind(localPort).sync() : serverBootstrap.bind(host, localPort).sync();\n\t\t\tlog.info(\"Listening on {}, port {}\", channel.channel().localAddress(), localPort);\n\t\t}\n\t\tcatch (InterruptedException e)\n\t\t{\n\t\t\tThread.currentThread().interrupt();\n\t\t\tthrow new IllegalStateException(\"Interrupted: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tpublic void stop()\n\t{\n\t\tif (channel == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (StartupProperties.getBoolean(FAST_SHUTDOWN, false))\n\t\t{\n\t\t\tlog.debug(\"Shutting down peer server (fast)...\");\n\t\t\tworkerGroup.shutdownGracefully();\n\t\t\tbossGroup.shutdownGracefully();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.info(\"Shutting down peer server...\");\n\t\t\ttry\n\t\t\t{\n\t\t\t\tworkerGroup.shutdownGracefully().sync();\n\t\t\t\tbossGroup.shutdownGracefully().sync();\n\t\t\t}\n\t\t\tcatch (InterruptedException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Error while shutting down peer server: {}\", e.getMessage());\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerTcpClient.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.netty.resolver.AddressResolverGroup;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport org.springframework.stereotype.Component;\n\nimport java.net.SocketAddress;\n\nimport static io.xeres.app.net.peer.ConnectionType.TCP_OUTGOING;\n\n@Component\npublic class PeerTcpClient extends PeerClient\n{\n\tpublic PeerTcpClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tsuper(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic PeerInitializer getPeerInitializer()\n\t{\n\t\treturn new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, TCP_OUTGOING, profileService, uiBridgeService, rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic AddressResolverGroup<? extends SocketAddress> getAddressResolverGroup()\n\t{\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerTcpServer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class PeerTcpServer extends PeerServer\n{\n\tpublic PeerTcpServer(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tsuper(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/bootstrap/PeerTorClient.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.bootstrap;\n\nimport io.netty.resolver.AddressResolverGroup;\nimport io.netty.resolver.NoopAddressResolverGroup;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport org.springframework.stereotype.Component;\n\nimport java.net.SocketAddress;\n\nimport static io.xeres.app.net.peer.ConnectionType.TOR_OUTGOING;\n\n@Component\npublic class PeerTorClient extends PeerClient\n{\n\tpublic PeerTorClient(SettingsService settingsService, NetworkProperties networkProperties, ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tsuper(settingsService, networkProperties, profileService, locationService, peerConnectionManager, databaseSessionManager, serviceInfoRsService, uiBridgeService, rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic PeerInitializer getPeerInitializer()\n\t{\n\t\treturn new PeerInitializer(peerConnectionManager, databaseSessionManager, locationService, settingsService, networkProperties, serviceInfoRsService, TOR_OUTGOING, profileService, uiBridgeService, rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic AddressResolverGroup<? extends SocketAddress> getAddressResolverGroup()\n\t{\n\t\treturn NoopAddressResolverGroup.INSTANCE;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/packet/MultiPacket.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.packet;\n\nimport io.netty.buffer.ByteBuf;\n\nimport java.net.ProtocolException;\n\n/**\n * This packet supports slicing and grouping for a more efficient\n * transmission over an SSL link.\n */\npublic class MultiPacket extends Packet\n{\n\t/**\n\t * Maximum packet ID. Wraps around.\n\t */\n\tpublic static final int MAXIMUM_ID = 16_777_216;\n\n\t/**\n\t * Flag set for starting packets and full packets\n\t * in the new format.\n\t */\n\tpublic static final int SLICE_FLAG_START = 1;\n\n\t/**\n\t * Flag set for ending packets and full packets\n\t * in the new format.\n\t */\n\tpublic static final int SLICE_FLAG_END = 2;\n\n\tprivate static final int HEADER_VERSION_INDEX = 0;\n\tprivate static final int HEADER_FLAG_INDEX = 1;\n\tprivate static final int HEADER_PACKET_ID_INDEX = 2;\n\tpublic static final int HEADER_SIZE_INDEX = 6;\n\n\tprotected static boolean isNewPacket(ByteBuf in) throws ProtocolException\n\t{\n\t\tif (in.getUnsignedByte(HEADER_VERSION_INDEX) == SLICE_PROTOCOL_VERSION_ID_01)\n\t\t{\n\t\t\tvar id = (int) in.getUnsignedInt(HEADER_PACKET_ID_INDEX);\n\t\t\tif (id >= MAXIMUM_ID || id < 0)\n\t\t\t{\n\t\t\t\tthrow new ProtocolException(\"Illegal packet id (\" + id + \")\");\n\t\t\t}\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprotected MultiPacket(ByteBuf in)\n\t{\n\t\tbuf = in.retain();\n\t}\n\n\tpublic void setStart()\n\t{\n\t\taddFlags(SLICE_FLAG_START);\n\t}\n\n\tpublic boolean isStart()\n\t{\n\t\treturn (getFlags() & SLICE_FLAG_START) == SLICE_FLAG_START;\n\t}\n\n\tpublic void setEnd()\n\t{\n\t\taddFlags(SLICE_FLAG_END);\n\t}\n\n\tpublic boolean isEnd()\n\t{\n\t\treturn (getFlags() & SLICE_FLAG_END) == SLICE_FLAG_END;\n\t}\n\n\tpublic boolean isMiddle()\n\t{\n\t\treturn !(isStart() || isEnd());\n\t}\n\n\tpublic boolean isSlice()\n\t{\n\t\treturn !isComplete();\n\t}\n\n\tpublic void setId(int id)\n\t{\n\t\tbuf.setInt(HEADER_PACKET_ID_INDEX, id);\n\t}\n\n\tpublic int getId()\n\t{\n\t\treturn (int) buf.getUnsignedInt(HEADER_PACKET_ID_INDEX);\n\t}\n\n\t@Override\n\tpublic int getSize()\n\t{\n\t\treturn buf.getUnsignedShort(HEADER_SIZE_INDEX);\n\t}\n\n\t@Override\n\tpublic ByteBuf getItemBuffer()\n\t{\n\t\treturn getBuffer().slice(HEADER_SIZE, getSize());\n\t}\n\n\tprivate int getFlags()\n\t{\n\t\treturn buf.getUnsignedByte(HEADER_FLAG_INDEX);\n\t}\n\n\tprivate void addFlags(int newFlags)\n\t{\n\t\tint currentFlags = buf.getUnsignedByte(HEADER_FLAG_INDEX);\n\t\tcurrentFlags |= newFlags;\n\t\tbuf.setByte(HEADER_FLAG_INDEX, currentFlags);\n\t}\n\n\tprivate void removeFlags(int newFlags)\n\t{\n\t\tint currentFlags = buf.getUnsignedByte(HEADER_FLAG_INDEX);\n\t\tcurrentFlags &= ~newFlags;\n\t\tbuf.setByte(HEADER_FLAG_INDEX, currentFlags);\n\t}\n\n\t@Override\n\tpublic boolean isComplete()\n\t{\n\t\treturn isStart() && isEnd();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/packet/Packet.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.packet;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.item.RawItem;\n\nimport java.net.ProtocolException;\nimport java.util.Objects;\n\npublic abstract class Packet implements Comparable<Packet>\n{\n\t/**\n\t * Version of the packet protocol with slicing and grouping support.\n\t * Also referred as new format.\n\t */\n\tpublic static final int SLICE_PROTOCOL_VERSION_ID_01 = 16;\n\n\t/**\n\t * Size of the header. Same for both packet protocols.\n\t */\n\tpublic static final int HEADER_SIZE = 8;\n\n\t/**\n\t * Optimal packet size for the new format. It fits better\n\t * in the SSL encapsulation.\n\t */\n\tpublic static final int OPTIMAL_PACKET_SIZE = 512;\n\n\t/**\n\t * The maximum packet size, which is the buffer size per connection\n\t * used by Retroshare, actually.\n\t */\n\tpublic static final int MAXIMUM_PACKET_SIZE = 262_143;\n\n\tprotected int priority = 3;\n\tprivate int sequence;\n\n\tprotected ByteBuf buf;\n\n\tpublic static Packet fromItem(RawItem rawItem)\n\t{\n\t\tPacket packet;\n\t\t//if (rawItem.getPacketVersion() == 2) // this handles slice prods, which HAVE to use the old format, for now\n\t\t//{\n\t\t//\tpacket = new SimplePacket(rawItem.getBuffer());\n\t\t//}\n\t\t//return new MultiPacket(item.getBuffer()); // XXX: when the encoder is ready\n\t\tpacket = new SimplePacket(rawItem.getBuffer());\n\t\tpacket.setPriority(rawItem.getPriority());\n\t\treturn packet;\n\t}\n\n\tpublic static Packet fromBuffer(ByteBuf in) throws ProtocolException\n\t{\n\t\treturn MultiPacket.isNewPacket(in) ? new MultiPacket(in) : new SimplePacket(in);\n\t}\n\n\tprotected Packet()\n\t{\n\t}\n\n\tpublic boolean isMulti()\n\t{\n\t\treturn this instanceof MultiPacket;\n\t}\n\n\tpublic abstract boolean isComplete();\n\n\tpublic abstract int getSize();\n\n\tpublic abstract ByteBuf getItemBuffer();\n\n\tvoid setBuffer(ByteBuf buf) // XXX: for tests... check if it works well enough\n\t{\n\t\tthis.buf = buf;\n\t}\n\n\tpublic ByteBuf getBuffer()\n\t{\n\t\treturn buf;\n\t}\n\n\tpublic int getPriority()\n\t{\n\t\treturn priority;\n\t}\n\n\tpublic void setPriority(int priority)\n\t{\n\t\tthis.priority = priority;\n\t}\n\n\tpublic boolean isRealtimePriority()\n\t{\n\t\treturn priority == 9; // XXX: make it nicer\n\t}\n\n\tpublic void setSequence(int sequence) // XXX: possibly in new packets only. not sure though\n\t{\n\t\tthis.sequence = sequence;\n\t}\n\n\tpublic void dispose()\n\t{\n\t\tbuf.release();\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar packet = (Packet) o;\n\t\treturn priority == packet.priority && sequence == packet.sequence && buf.equals(packet.buf);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(priority, sequence, buf);\n\t}\n\n\t@Override\n\tpublic int compareTo(Packet o)\n\t{\n\t\tvar res = getPriority() - o.getPriority();\n\n\t\tif (res == 0 && o != this)\n\t\t{\n\t\t\tres = o.sequence - sequence;\n\t\t}\n\t\treturn res;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/packet/SimplePacket.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.packet;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.channel.ChannelHandlerContext;\n\nimport java.util.List;\n\n/**\n * This is the old packet format of RS. It is still\n * used by RS in some cases (for example, transmission of a single small packet).\n */\npublic class SimplePacket extends Packet\n{\n\tpublic static final int HEADER_SIZE_INDEX = 4;\n\n\tprotected SimplePacket(ByteBuf in)\n\t{\n\t\tbuf = in.retain();\n\t}\n\n\tpublic SimplePacket(ChannelHandlerContext ctx, List<MultiPacket> packets)\n\t{\n\t\tpriority = packets.stream().findFirst().orElseThrow().getPriority();\n\t\tbuf = ctx.alloc().buffer();\n\t\tpackets.forEach(packet -> {\n\t\t\tbuf.writeBytes(packet.getBuffer(), HEADER_SIZE, packet.getSize());\n\t\t\tpacket.dispose();\n\t\t});\n\t}\n\n\t@Override\n\tpublic int getSize()\n\t{\n\t\treturn (int) buf.getUnsignedInt(HEADER_SIZE_INDEX);\n\t}\n\n\t@Override\n\tpublic ByteBuf getItemBuffer()\n\t{\n\t\treturn getBuffer();\n\t}\n\n\t@Override\n\tpublic boolean isComplete()\n\t{\n\t\treturn true;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/packet/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Packet format for sending and receiving data.\n * <p>There are 2 header formats for Packets:\n * <p>Old (describing a SimplePacket):<br>\n * <pre>\n * +---------+---------+-----------+--------------------------------------+\n * | version | service | subpacket | size, <b>including</b> header of 8 bytes    |\n * +---------+---------+-----------+--------------------------------------+\n * | 1 byte  | 2 bytes |   1 byte  |                4 bytes               |\n * +---------+---------+-----------+--------------------------------------+\n * </pre>\n * <p>New (describing a MultiPacket, version is always 16):<br>\n * <pre>\n * +---------+---------+------------+--------------------------------------+\n * | version |  flags  | packet id  | size, <b>excluding</b> header of 8 bytes    |\n * +---------+---------+------------+--------------------------------------+\n * | 1 byte  | 1 byte  |   4 bytes  |               2 bytes                |\n * +---------+---------+------------+--------------------------------------+\n * </pre>\n * <p>\n * Checking the protocol version (16) is enough to know if it's a new packet format. The simple packet\n * format is just the Item. The multi packet format is basically the slicing header and the Item as data. It\n * allows grouping and slicing to fit better into 512 bytes long data packets.\n */\npackage io.xeres.app.net.peer.packet;\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/IdleEventHandler.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.channel.ChannelHandler;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.channel.SimpleUserEventChannelHandler;\nimport io.netty.handler.timeout.IdleState;\nimport io.netty.handler.timeout.IdleStateEvent;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.time.Duration;\n\n/**\n * Event handler that automatically closes the connection if the peer doesn't send anything\n * during a certain time. We also send a SliceProbeItem if we're idle ourselves (which is unlikely\n * to happen during normal operations (for example, RTT and heartbeat services)).\n */\n@ChannelHandler.Sharable\npublic class IdleEventHandler extends SimpleUserEventChannelHandler<IdleStateEvent>\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(IdleEventHandler.class);\n\n\tprivate final Duration timeout;\n\n\tpublic IdleEventHandler(Duration timeout)\n\t{\n\t\tsuper();\n\t\tthis.timeout = timeout;\n\t}\n\n\t@Override\n\tprotected void eventReceived(ChannelHandlerContext ctx, IdleStateEvent evt)\n\t{\n\t\tif (evt.state() == IdleState.READER_IDLE)\n\t\t{\n\t\t\tlog.info(\"No activity for {} seconds, closing channel of {}\", timeout.toSeconds(), ctx.channel().remoteAddress());\n\t\t\tctx.close();\n\t\t}\n\t\telse if (evt.state() == IdleState.WRITER_IDLE)\n\t\t{\n\t\t\tlog.info(\"Sending idle slicing probe\");\n\t\t\tPeerConnectionManager.writeSliceProbe(ctx);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/ItemDecoder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.handler.codec.MessageToMessageDecoder;\nimport io.xeres.app.net.peer.packet.MultiPacket;\nimport io.xeres.app.net.peer.packet.Packet;\nimport io.xeres.app.net.peer.packet.SimplePacket;\nimport io.xeres.app.xrs.item.RawItem;\n\nimport java.net.ProtocolException;\nimport java.util.*;\n\n/**\n * Decodes RS Packets and produces a RawItem.\n */\npublic class ItemDecoder extends MessageToMessageDecoder<ByteBuf>\n{\n\tprivate static final int MAX_SLICES = 195_512; // maximum number of slices per packets (XXX: does RS have a limit there? I don't think so actually)\n\tprivate static final int MAX_CONCURRENT_PACKETS = 16; // maximum number of concurrent packets\n\tprivate final Map<Integer, List<MultiPacket>> accumulator = HashMap.newHashMap(MAX_CONCURRENT_PACKETS);\n\n\t@Override\n\tprotected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws ProtocolException\n\t{\n\t\tvar packet = Packet.fromBuffer(in);\n\n\t\tif (packet.isMulti())\n\t\t{\n\t\t\tdecodeNewPacket(ctx, (MultiPacket) packet, out);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tout.add(new RawItem(packet));\n\t\t}\n\t}\n\n\t// XXX: when an error happens (eg. slices exceeded), we should remove (dispose) all packets from the accumulator and refuse any further packet with such id because they're incomplete and will reach the decoding stage (and fail there)\n\tprivate void decodeNewPacket(ChannelHandlerContext ctx, MultiPacket packet, List<Object> out) throws ProtocolException\n\t{\n\t\tif (packet.isComplete())\n\t\t{\n\t\t\tif (accumulator.containsKey(packet.getId()))\n\t\t\t{\n\t\t\t\tthrow new ProtocolException(\"Start packet \" + packet.getId() + \" already received\");\n\t\t\t}\n\t\t\tout.add(new RawItem(packet));\n\t\t}\n\t\telse if (packet.isStart())\n\t\t{\n\t\t\tif (accumulator.containsKey(packet.getId()))\n\t\t\t{\n\t\t\t\tthrow new ProtocolException(\"Start packet \" + packet.getId() + \" already received\");\n\t\t\t}\n\t\t\tif (accumulator.size() > MAX_CONCURRENT_PACKETS)\n\t\t\t{\n\t\t\t\tthrow new ProtocolException(\"Too many concurrent packets (\" + accumulator.size() + \")\");\n\t\t\t}\n\t\t\tvar list = new ArrayList<MultiPacket>();\n\t\t\tlist.add(packet);\n\t\t\taccumulator.put(packet.getId(), list);\n\t\t}\n\t\telse if (packet.isMiddle())\n\t\t{\n\t\t\tvar list = Optional.ofNullable(accumulator.get(packet.getId())).orElseThrow(() -> new ProtocolException(\"Middle packet \" + packet.getId() + \" received without corresponding start packet\"));\n\t\t\tif (list.size() > MAX_SLICES)\n\t\t\t{\n\t\t\t\tthrow new ProtocolException(\"Packet \" + packet.getId() + \" has too many slices (\" + list.size() + \")\");\n\t\t\t}\n\t\t\tlist.add(packet);\n\t\t}\n\t\telse if (packet.isEnd())\n\t\t{\n\t\t\tvar list = Optional.ofNullable(accumulator.remove(packet.getId())).orElseThrow(() -> new ProtocolException(\"End packet \" + packet.getId() + \" received without corresponding start packet\"));\n\t\t\tlist.add(packet);\n\t\t\tout.add(new RawItem(new SimplePacket(ctx, list)));\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/ItemEncoder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.channel.ChannelHandler;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.handler.codec.MessageToMessageEncoder;\nimport io.xeres.app.net.peer.packet.Packet;\nimport io.xeres.app.xrs.item.RawItem;\n\nimport java.util.List;\n\n@ChannelHandler.Sharable\npublic class ItemEncoder extends MessageToMessageEncoder<RawItem>\n{\n\t@Override\n\tprotected void encode(ChannelHandlerContext ctx, RawItem msg, List<Object> out)\n\t{\n\t\tout.add(Packet.fromItem(msg));\n\t\tmsg.dispose();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/MultiPacketEncoder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.channel.ChannelOutboundHandlerAdapter;\nimport org.apache.commons.lang3.NotImplementedException;\n\npublic class MultiPacketEncoder extends ChannelOutboundHandlerAdapter\n{\n\tpublic MultiPacketEncoder()\n\t{\n\t\tthrow new NotImplementedException(\"MultiPacketEncoder is not available yet, see #32\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/PacketDecoder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.handler.codec.ByteToMessageDecoder;\nimport io.netty.handler.codec.TooLongFrameException;\nimport io.xeres.app.net.peer.packet.MultiPacket;\nimport io.xeres.app.net.peer.packet.Packet;\nimport io.xeres.app.net.peer.packet.SimplePacket;\n\nimport java.net.ProtocolException;\nimport java.util.List;\n\nimport static io.xeres.app.net.peer.packet.MultiPacket.MAXIMUM_PACKET_SIZE;\nimport static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE;\n\n/**\n * Decodes incoming frames into packets.\n */\npublic class PacketDecoder extends ByteToMessageDecoder\n{\n\t@Override\n\tprotected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception\n\t{\n\t\tif (in.readableBytes() >= HEADER_SIZE)\n\t\t{\n\t\t\tlong size;\n\n\t\t\tif (in.getUnsignedByte(in.readerIndex()) == Packet.SLICE_PROTOCOL_VERSION_ID_01)\n\t\t\t{\n\t\t\t\tsize = (long) in.getUnsignedShort(in.readerIndex() + MultiPacket.HEADER_SIZE_INDEX) + HEADER_SIZE;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsize = in.getUnsignedInt(in.readerIndex() + SimplePacket.HEADER_SIZE_INDEX);\n\t\t\t}\n\n\t\t\tif (size >= MAXIMUM_PACKET_SIZE - HEADER_SIZE)\n\t\t\t{\n\t\t\t\tthrow new TooLongFrameException(\"Frame is too long: \" + size);\n\t\t\t}\n\t\t\telse if (size < HEADER_SIZE)\n\t\t\t{\n\t\t\t\tthrow new ProtocolException(\"Packet size too small, size: \" + size);\n\t\t\t}\n\n\t\t\tif (in.readableBytes() >= size)\n\t\t\t{\n\t\t\t\tout.add(in.readBytes((int) size));\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/PeerHandler.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.channel.ChannelDuplexHandler;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.handler.codec.TooLongFrameException;\nimport io.netty.handler.ssl.SslHandler;\nimport io.netty.handler.ssl.SslHandshakeCompletionEvent;\nimport io.netty.util.ReferenceCountUtil;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.ConnectionType;\nimport io.xeres.app.net.peer.PeerAttribute;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.net.peer.ssl.SSL;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.RawItem;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.ServiceInfoRsService;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport javax.net.ssl.SSLPeerUnverifiedException;\nimport java.io.IOException;\nimport java.security.cert.CertificateException;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.app.net.peer.ConnectionType.TCP_INCOMING;\nimport static io.xeres.common.tray.TrayNotificationType.CONNECTION;\n\npublic class PeerHandler extends ChannelDuplexHandler\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(PeerHandler.class);\n\n\tprivate final ConnectionType connectionType;\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ServiceInfoRsService serviceInfoRsService;\n\tprivate final UiBridgeService uiBridgeService;\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\n\tpublic PeerHandler(ProfileService profileService, LocationService locationService, PeerConnectionManager peerConnectionManager, DatabaseSessionManager databaseSessionManager, ServiceInfoRsService serviceInfoRsService, ConnectionType connectionType, UiBridgeService uiBridgeService, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tsuper();\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.serviceInfoRsService = serviceInfoRsService;\n\t\tthis.connectionType = connectionType;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t}\n\n\t@Override\n\tpublic void channelRead(ChannelHandlerContext ctx, Object msg)\n\t{\n\t\tvar peerConnection = ctx.channel().attr(PeerAttribute.PEER_CONNECTION).get();\n\n\t\t// Drop messages if SSL peer is not validated\n\t\tif (peerConnection == null)\n\t\t{\n\t\t\tlog.warn(\"Dropping message as SSL not validated\");\n\t\t\t((RawItem) msg).dispose();\n\t\t\tReferenceCountUtil.release(msg);\n\t\t\treturn;\n\t\t}\n\n\t\tlog.trace(\"Got message: {}\", msg);\n\t\tvar rawItem = (RawItem) msg;\n\t\tItem item = null;\n\t\tvar sessionBound = false;\n\t\tpeerConnection.incrementReceivedCounter(rawItem.getSize());\n\n\t\ttry\n\t\t{\n\t\t\titem = rsServiceRegistry.buildIncomingItem(rawItem);\n\t\t\tlog.debug(\"<== {}\", item.getClass().getSimpleName());\n\t\t\trawItem.deserialize(item);\n\t\t\tlog.debug(\"   \\\\- : {}\", item);\n\n\t\t\tvar service = rsServiceRegistry.getServiceFromType(item.getServiceType());\n\t\t\tif (service != null)\n\t\t\t{\n\t\t\t\tvar handleItemMethod = service.getClass().getDeclaredMethod(\"handleItem\", PeerConnection.class, Item.class);\n\t\t\t\tif (handleItemMethod.isAnnotationPresent(Transactional.class))\n\t\t\t\t{\n\t\t\t\t\tsessionBound = databaseSessionManager.bindSession();\n\t\t\t\t}\n\t\t\t\tservice.handleItem(peerConnection, item);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Unknown item (service: {}, subtype: {}). Ignoring.\", item.getServiceType(), item.getSubType());\n\t\t\t}\n\t\t}\n\t\tcatch (Exception e) // NOSONAR: We need to catch all exceptions here otherwise it's invisible\n\t\t{\n\t\t\tlog.error(\"Failed to deserialize item {}\", item, e);\n\t\t\trawItem.dispose();\n\t\t\titem = null; // Don't dispose twice\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif (sessionBound)\n\t\t\t{\n\t\t\t\tdatabaseSessionManager.unbindSession();\n\t\t\t}\n\n\t\t\tif (item != null)\n\t\t\t{\n\t\t\t\titem.dispose(); // Dispose the item\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)\n\t{\n\t\tvar peerConnection = ctx.channel().attr(PeerAttribute.PEER_CONNECTION).get();\n\t\tvar remote = peerConnection != null ? peerConnection : ctx.channel().remoteAddress();\n\n\t\tif (cause instanceof TooLongFrameException || cause instanceof IOException)\n\t\t{\n\t\t\tif (log.isDebugEnabled())\n\t\t\t{\n\t\t\t\tlog.debug(\"Error in channel of {} (closing connection): \", remote, cause);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.error(\"Error in channel of {} (closing connection): {}\", remote, cause.getMessage());\n\t\t\t}\n\t\t\tctx.close();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Error in channel of {}:\", remote, cause);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void channelActive(ChannelHandlerContext ctx)\n\t{\n\t\tlog.debug(\"{} connection with {}\", connectionType == TCP_INCOMING ? \"Incoming\" : \"Outgoing\", ctx.channel().remoteAddress());\n\t\tctx.channel().attr(PeerAttribute.MULTI_PACKET).set(false);\n\t}\n\n\t@Override\n\tpublic void userEventTriggered(ChannelHandlerContext ctx, Object evt)\n\t{\n\t\tif (evt instanceof SslHandshakeCompletionEvent sslHandshakeCompletionEvent)\n\t\t{\n\t\t\tif (!sslHandshakeCompletionEvent.isSuccess())\n\t\t\t{\n\t\t\t\tlog.debug(\"SSL handshake failed\"); // There doesn't seem to ever be a useful message in the even so we don't display any\n\t\t\t\tctx.close();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t\t{\n\t\t\t\tLocation location;\n\n\t\t\t\tsynchronized (PeerHandler.class) // Make sure we cannot have an outgoing and incoming connection with the same peer at the same time\n\t\t\t\t{\n\t\t\t\t\tlocation = SSL.checkPeerCertificate(profileService, locationService, ctx.pipeline().get(SslHandler.class).engine().getSession().getPeerCertificates());\n\t\t\t\t\tlocationService.setConnected(location, ctx.channel().remoteAddress());\n\t\t\t\t\tvar peerConnection = peerConnectionManager.addPeer(location, ctx);\n\t\t\t\t\tpeerConnection.schedule(() -> serviceInfoRsService.init(peerConnection), ThreadLocalRandom.current().nextInt(2, 9), TimeUnit.SECONDS);\n\t\t\t\t}\n\n\t\t\t\tvar message = \"Established \" + connectionType.getDescription() + \" connection with \" + location.getProfile().getName() + \" (\" + location.getSafeName() + \")\";\n\n\t\t\t\tlog.info(message);\n\t\t\t\tuiBridgeService.showTrayNotification(CONNECTION, message);\n\n\t\t\t\tsendSliceProbe(ctx);\n\t\t\t}\n\t\t\tcatch (CertificateException | SSLPeerUnverifiedException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Certificate error: {}\", e.getMessage());\n\t\t\t\tctx.close();\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void channelInactive(ChannelHandlerContext ctx)\n\t{\n\t\tvar peerConnection = ctx.channel().attr(PeerAttribute.PEER_CONNECTION).get();\n\t\tvar remote = peerConnection != null ? peerConnection : ctx.channel().remoteAddress();\n\t\tlog.debug(\"Closing connection with {}\", remote);\n\n\t\tif (peerConnection != null)\n\t\t{\n\t\t\tif (!log.isDebugEnabled())\n\t\t\t{\n\t\t\t\tlog.warn(\"Closing connection with {}\", remote);\n\t\t\t}\n\t\t\tpeerConnection.cleanup();\n\t\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t\t{\n\t\t\t\tlocationService.setDisconnected(peerConnection.getLocation());\n\t\t\t}\n\t\t\tpeerConnectionManager.removePeer(peerConnection.getLocation());\n\t\t}\n\t}\n\n\tprivate static void sendSliceProbe(ChannelHandlerContext ctx)\n\t{\n\t\tPeerConnectionManager.writeSliceProbe(ctx); // this makes the remote RS send packets in the new format\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/SimplePacketEncoder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.pipeline;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.channel.ChannelHandler;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.handler.codec.MessageToByteEncoder;\nimport io.xeres.app.net.peer.packet.Packet;\n\n@ChannelHandler.Sharable\npublic class SimplePacketEncoder extends MessageToByteEncoder<Packet>\n{\n\t@Override\n\tprotected void encode(ChannelHandlerContext ctx, Packet msg, ByteBuf out)\n\t{\n\t\tctx.writeAndFlush(msg.getBuffer()); // nothing to do, just send the buffer\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/pipeline/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Pipeline process.\n * <p>It works in the following way.\n * <p>For incoming packets\n * <pre>incoming bytes -> Packet -> Item -> deserialization -> service data</pre>\n * <p>For outgoing packets\n * <pre>service data -> serialization -> Item -> Packet -> outgoing bytes</pre>\n * <p>Right now, the packet encoder sends simple packets. It'll be upgraded to send multi packets later.\n * Both multi packets and simple packets are accepted as input.\n */\npackage io.xeres.app.net.peer.pipeline;\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/peer/ssl/SSL.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.ssl;\n\nimport io.netty.handler.ssl.ClientAuth;\nimport io.netty.handler.ssl.SslContext;\nimport io.netty.handler.ssl.SslContextBuilder;\nimport io.netty.handler.ssl.SslProvider;\nimport io.netty.handler.ssl.util.InsecureTrustManagerFactory;\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.crypto.rsid.RSSerialVersion;\nimport io.xeres.app.crypto.x509.X509;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.net.peer.ConnectionType;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.common.id.LocationIdentifier;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.net.ssl.SSLException;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SignatureException;\nimport java.security.cert.Certificate;\nimport java.security.cert.CertificateEncodingException;\nimport java.security.cert.CertificateException;\nimport java.security.cert.X509Certificate;\nimport java.security.spec.InvalidKeySpecException;\nimport java.util.Locale;\nimport java.util.regex.Pattern;\n\nimport static io.xeres.app.net.peer.ConnectionType.TCP_INCOMING;\n\npublic final class SSL\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SSL.class);\n\n\tprivate static final Pattern ISSUER_MATCHER = Pattern.compile(\"^CN=(\\\\p{XDigit}{16})$\");\n\n\tprivate SSL()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Creates an SSL context.\n\t *\n\t * @param privateKeyData the private key\n\t * @param certificate    the certificate\n\t * @param connectionType the connection type (incoming for a server, outgoing for a client)\n\t * @return the ssl context\n\t * @throws InvalidKeySpecException  if the private key is bad\n\t * @throws NoSuchAlgorithmException if the private key has an unsupported key algorithm\n\t * @throws SSLException             if there's an SSL error\n\t */\n\tpublic static SslContext createSslContext(byte[] privateKeyData, X509Certificate certificate, ConnectionType connectionType) throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException\n\t{\n\t\tSslContextBuilder builder;\n\t\tif (connectionType == TCP_INCOMING)\n\t\t{\n\t\t\tbuilder = SslContextBuilder.forServer(RSA.getPrivateKey(privateKeyData), certificate);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbuilder = SslContextBuilder.forClient()\n\t\t\t\t\t.endpointIdentificationAlgorithm(null) // No hostname verification. Would be impractical in a P2P setup\n\t\t\t\t\t.keyManager(RSA.getPrivateKey(privateKeyData), certificate);\n\t\t}\n\t\treturn builder\n\t\t\t\t.sslProvider(SslProvider.JDK)\n\t\t\t\t.protocols(\"TLSv1.3\")\n\t\t\t\t.clientAuth(ClientAuth.REQUIRE)\n\t\t\t\t.trustManager(InsecureTrustManagerFactory.INSTANCE)\n\t\t\t\t.build();\n\t}\n\n\t/**\n\t * Checks if a certificate is valid. Either it matches a location that we already have or it's the location of a profile that\n\t * we have accepted. In the later case, the new location is also created with a null name that will be updated later\n\t * using discovery.\n\t *\n\t * @param profileService  the profile service\n\t * @param locationService the location service\n\t * @param chain           the certificate chain\n\t * @return the location\n\t * @throws CertificateException if the location is not allowed\n\t */\n\tpublic static Location checkPeerCertificate(ProfileService profileService, LocationService locationService, Certificate[] chain) throws CertificateException\n\t{\n\t\tvar isNewLocation = false;\n\n\t\tif (chain == null || chain.length == 0)\n\t\t{\n\t\t\tthrow new CertificateException(\"Empty certificate\");\n\t\t}\n\n\t\tvar x509Certificate = X509.getCertificate(chain[0].getEncoded());\n\n\t\tvar locationIdentifier = X509.getLocationIdentifier(x509Certificate);\n\t\tlog.debug(\"SSL ID: {}\", locationIdentifier);\n\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElse(null);\n\t\tif (location == null)\n\t\t{\n\t\t\tlocation = createLocationIfAcceptedProfile(locationIdentifier, x509Certificate, profileService);\n\t\t\tif (location == null)\n\t\t\t{\n\t\t\t\tthrow new CertificateException(\"Unknown location (SSL ID: \" + locationIdentifier + \")\");\n\t\t\t}\n\t\t\tisNewLocation = true;\n\t\t}\n\t\tlog.debug(\"Found location: {} {}\", location.getSafeName(), location.isConnected() ? \", is already connected\" : \"\");\n\t\tif (location.isConnected())\n\t\t{\n\t\t\tthrow new CertificateException(\"Already connected\");\n\t\t}\n\n\t\tif (location.getProfile().isComplete())\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tverify(PGP.getPGPPublicKey(location.getProfile().getPgpPublicKeyData()), x509Certificate);\n\t\t\t\tif (isNewLocation)\n\t\t\t\t{\n\t\t\t\t\tprofileService.createOrUpdateProfile(location.getProfile());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (InvalidKeyException e)\n\t\t\t{\n\t\t\t\tthrow new CertificateException(e.getMessage(), e);\n\t\t\t}\n\t\t}\n\t\treturn location;\n\t}\n\n\tprivate static Location createLocationIfAcceptedProfile(LocationIdentifier locationIdentifier, X509Certificate x509Certificate, ProfileService profileService)\n\t{\n\t\tvar issuer = x509Certificate.getIssuerX500Principal().getName();\n\t\tvar matcher = ISSUER_MATCHER.matcher(issuer);\n\n\t\tif (matcher.matches())\n\t\t{\n\t\t\tvar pgpIdentifier = Long.parseUnsignedLong(matcher.group(1).toLowerCase(Locale.ROOT), 16);\n\n\t\t\tvar profile = profileService.findProfileByPgpIdentifier(pgpIdentifier)\n\t\t\t\t\t.filter(Profile::isComplete)\n\t\t\t\t\t.filter(Profile::isAccepted)\n\t\t\t\t\t.orElse(null);\n\n\t\t\tif (profile != null)\n\t\t\t{\n\t\t\t\treturn Location.createLocation(null, profile, locationIdentifier);\n\t\t\t}\n\t\t\tlog.debug(\"No profile found for location: {}\", locationIdentifier);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Couldn't match PGP key from certificate issuer: {}\", issuer);\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate static void verify(PGPPublicKey pgpPublicKey, X509Certificate cert) throws CertificateException\n\t{\n\t\tvar version = RSSerialVersion.getFromSerialNumber(cert.getSerialNumber());\n\t\tlog.debug(\"Certificate version: {}\", version);\n\n\t\ttry\n\t\t{\n\t\t\tvar in = cert.getTBSCertificate();\n\n\t\t\tif (version.ordinal() < RSSerialVersion.V07_0001.ordinal())\n\t\t\t{\n\t\t\t\t// If this is a 0.6 certificate, the signature verification is performed\n\t\t\t\t// on the hash of the certificate\n\t\t\t\tvar md = new Sha1MessageDigest();\n\t\t\t\tmd.update(in);\n\t\t\t\tin = md.getBytes();\n\t\t\t}\n\t\t\tPGP.verify(pgpPublicKey, cert.getSignature(), new ByteArrayInputStream(in));\n\t\t}\n\t\tcatch (CertificateEncodingException | IOException | SignatureException | PGPException e)\n\t\t{\n\t\t\tthrow new CertificateException(e);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/protocol/DomainNameSocketAddress.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.protocol;\n\nimport java.io.Serial;\nimport java.net.SocketAddress;\n\npublic final class DomainNameSocketAddress extends SocketAddress\n{\n\t@Serial\n\tprivate static final long serialVersionUID = -551345992744929084L;\n\t\n\tprivate final String name;\n\n\tprivate DomainNameSocketAddress(String name)\n\t{\n\t\tif (name.contains(\":\"))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"DomainNameSocketAddress is only usable for domains alone, not domain/ports\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.name = name;\n\t\t}\n\t}\n\n\tpublic static DomainNameSocketAddress of(String name)\n\t{\n\t\treturn new DomainNameSocketAddress(name);\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/protocol/PeerAddress.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.protocol;\n\nimport io.xeres.common.protocol.HostPort;\nimport io.xeres.common.protocol.i2p.I2pAddress;\nimport io.xeres.common.protocol.ip.IP;\nimport io.xeres.common.protocol.tor.OnionAddress;\n\nimport java.net.InetAddress;\nimport java.net.InetSocketAddress;\nimport java.net.SocketAddress;\nimport java.net.UnknownHostException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.Optional;\nimport java.util.regex.Pattern;\n\nimport static io.xeres.app.net.protocol.PeerAddress.Type.*;\nimport static io.xeres.common.protocol.ip.IP.isInvalidPort;\nimport static java.util.function.Predicate.not;\n\n/**\n * A class that can contain any peer address.\n * <p>\n * Vocabulary:\n * <ul>\n * <li>url: a Retroshare URL (ipv4://192.168.1.1:80, etc...)</li>\n * <li>address: a string that can be an ipv4 socket or tor address (192.168.1.1:80, foobar.onion, ...)</li>\n * <li>ipAndPort: 192.168.1.1:80</li>\n * <li>socket address: an ip socket address, directly usable with java functions</li>\n * </ul>\n * <p>Creating a PeerAddress always succeed. Its validity can be checked with isValid().\n */\npublic final class PeerAddress\n{\n\tpublic enum Type\n\t{\n\t\tINVALID(\"\"),\n\t\tIPV4(\"ipv4://\"),\n\t\tIPV6(\"ipv6://\"),\n\t\tTOR(\"\"),\n\t\tHOSTNAME(\"\"),\n\t\tI2P(\"\");\n\n\t\tprivate final String scheme;\n\n\t\tType(String scheme)\n\t\t{\n\t\t\tthis.scheme = scheme;\n\t\t}\n\n\t\tpublic String scheme()\n\t\t{\n\t\t\treturn scheme;\n\t\t}\n\t}\n\n\tprivate static final Pattern HOSTNAME_OK_PATTERN = Pattern.compile(\"^(?:\\\\p{Alnum}(?>[\\\\p{Alnum}-]{0,61}\\\\p{Alnum})?\\\\.)+(\\\\p{Alpha}(?>[\\\\p{Alnum}-]{0,61}\\\\p{Alnum})?)\\\\.?$\");\n\n\tprivate SocketAddress socketAddress;\n\tprivate final Type type;\n\n\t/**\n\t * Creates a PeerAddress from a URL (ipv4://, etc...).\n\t *\n\t * @param url the URL\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromUrl(String url)\n\t{\n\t\tif (url == null)\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\n\t\tif (url.startsWith(IPV4.scheme()))\n\t\t{\n\t\t\treturn fromIpAndPort(url.substring(IPV4.scheme().length()));\n\t\t}\n\t\treturn fromInvalid();\n\t}\n\n\t/**\n\t * Creates a PeerAddress from an address (eg. juiejkslajfsk.onion, 85.12.33.11:8081, ...).\n\t *\n\t * @param address the address\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromAddress(String address)\n\t{\n\t\tif (address == null)\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\treturn tryFromHidden(address).orElse(tryFromIpAndPort(address).orElse(fromHostnameAndPort(address)));\n\t}\n\n\t/**\n\t * Creates a PeerAddress from a hidden address (Tor/I2P)\n\t *\n\t * @param address the address\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromHidden(String address)\n\t{\n\t\tif (address == null)\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\treturn tryFromHidden(address).orElse(fromInvalid());\n\t}\n\n\tprivate static Optional<PeerAddress> tryFromHidden(String address)\n\t{\n\t\tvar peerAddress = tryFromOnion(address);\n\t\tif (peerAddress.isEmpty())\n\t\t{\n\t\t\tpeerAddress = tryFromI2p(address);\n\t\t}\n\t\treturn peerAddress;\n\t}\n\n\tprivate static Optional<PeerAddress> tryFromIpAndPort(String address)\n\t{\n\t\tvar peerAddress = fromIpAndPort(address);\n\t\tif (peerAddress.isValid())\n\t\t{\n\t\t\treturn Optional.of(peerAddress);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t/**\n\t * Creates a PeerAddress from an IP and a port.\n\t *\n\t * @param ip   the IP address\n\t * @param port the port\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress from(String ip, int port)\n\t{\n\t\tif (isInvalidPort(port))\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\tif (isInvalidIpAddress(ip))\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\ttry\n\t\t{\n\t\t\treturn new PeerAddress(new InetSocketAddress(InetAddress.getByName(ip), port), IPV4);\n\t\t}\n\t\tcatch (UnknownHostException _)\n\t\t{\n\t\t\treturn fromInvalid(); // Won't happen anyway\n\t\t}\n\t}\n\n\t/**\n\t * Creates a PeerAddress from an \"ip:port\" string.\n\t *\n\t * @param ipAndPort a string in the form \"ip:port\"; for example, \"192.168.1.2:8002\"\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromIpAndPort(String ipAndPort)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar hostPort = HostPort.parse(ipAndPort);\n\t\t\treturn from(hostPort);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t}\n\n\tpublic static PeerAddress from(HostPort hostPort)\n\t{\n\t\treturn from(hostPort.host(), hostPort.port());\n\t}\n\n\t/**\n\t * Creates a PeerAddress from a RsCertificate byte array.\n\t *\n\t * @param data a byte array which is made of the 4 bytes of the IP and the 2 bytes of the port (big endian).\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromByteArray(byte[] data)\n\t{\n\t\tif (data == null || data.length != 6)\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\n\t\tvar ip = String.format(\"%d.%d.%d.%d\", Byte.toUnsignedInt(data[0]), Byte.toUnsignedInt(data[1]), Byte.toUnsignedInt(data[2]), Byte.toUnsignedInt(data[3]));\n\n\t\tif (isInvalidIpAddress(ip))\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\n\t\tvar port = Byte.toUnsignedInt(data[4]) << 8 | Byte.toUnsignedInt(data[5]);\n\t\tif (isInvalidPort(port))\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\treturn from(ip, port);\n\t}\n\n\tpublic static PeerAddress fromHostname(String hostname)\n\t{\n\t\tif (isInvalidHostname(hostname))\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\treturn new PeerAddress(DomainNameSocketAddress.of(hostname), HOSTNAME);\n\t}\n\n\tpublic static PeerAddress fromHostname(String hostname, int port)\n\t{\n\t\tif (isInvalidHostname(hostname) || isInvalidPort(port))\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t\treturn new PeerAddress(InetSocketAddress.createUnresolved(hostname, port), HOSTNAME);\n\t}\n\n\tpublic static PeerAddress fromHostnameAndPort(String hostnameAndPort)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar hostPort = HostPort.parse(hostnameAndPort);\n\t\t\tif (isInvalidHostname(hostPort.host()))\n\t\t\t{\n\t\t\t\treturn fromInvalid();\n\t\t\t}\n\t\t\treturn fromHostname(hostPort.host(), hostPort.port());\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\treturn fromInvalid();\n\t\t}\n\t}\n\n\tpublic static PeerAddress fromSocketAddress(SocketAddress socketAddress)\n\t{\n\t\treturn new PeerAddress(socketAddress, Type.IPV4);\n\t}\n\n\t/**\n\t * Creates a PeerAddress from an onion address (i.e. \"jskljfksdjk.onion\")\n\t *\n\t * @param onion the onion address\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromOnion(String onion)\n\t{\n\t\treturn tryFromOnion(onion).orElse(fromInvalid());\n\t}\n\n\tprivate static Optional<PeerAddress> tryFromOnion(String onion)\n\t{\n\t\tif (OnionAddress.isValidAddress(onion))\n\t\t{\n\t\t\tvar hostPort = HostPort.parse(onion);\n\t\t\treturn Optional.of(new PeerAddress(InetSocketAddress.createUnresolved(hostPort.host(), hostPort.port()), TOR));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tpublic static PeerAddress fromI2p(String i2p)\n\t{\n\t\treturn tryFromI2p(i2p).orElse(fromInvalid());\n\t}\n\n\tprivate static Optional<PeerAddress> tryFromI2p(String i2p)\n\t{\n\t\tif (I2pAddress.isValidAddress(i2p))\n\t\t{\n\t\t\tvar hostPort = HostPort.parse(i2p);\n\t\t\treturn Optional.of(new PeerAddress(InetSocketAddress.createUnresolved(hostPort.host(), hostPort.port()), I2P));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t/**\n\t * Creates an invalid PeerAddress.\n\t *\n\t * @return a PeerAddress\n\t */\n\tpublic static PeerAddress fromInvalid()\n\t{\n\t\treturn new PeerAddress(INVALID);\n\t}\n\n\tprivate PeerAddress(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tprivate PeerAddress(SocketAddress socketAddress, Type type)\n\t{\n\t\tthis.type = type;\n\t\tthis.socketAddress = socketAddress;\n\t}\n\n\t/**\n\t * Gets the SocketAddress of the PeerAddress (if the protocol allows it).\n\t *\n\t * @return a SocketAddress\n\t */\n\tpublic SocketAddress getSocketAddress()\n\t{\n\t\treturn socketAddress;\n\t}\n\n\t/**\n\t * Gets the address and port of the PeerAddress (if the protocol allows it), or any other suitable format.\n\t *\n\t * @return the IP address and port in the following format: \"ip:port\" or any other suitable format\n\t */\n\tpublic Optional<String> getAddress()\n\t{\n\t\tif (socketAddress instanceof InetSocketAddress inetSocketAddress)\n\t\t{\n\t\t\treturn Optional.of(inetSocketAddress.getHostString() + \":\" + inetSocketAddress.getPort());\n\t\t}\n\t\telse if (socketAddress instanceof DomainNameSocketAddress domainNameSocketAddress)\n\t\t{\n\t\t\treturn Optional.of(domainNameSocketAddress.getName());\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t/**\n\t * Gets the IP address and port in an array of bytes.\n\t *\n\t * @return the IP address in the 4 first bytes and the port in the 2 last ones (big endian).\n\t */\n\tpublic Optional<byte[]> getAddressAsBytes()\n\t{\n\t\tif (socketAddress instanceof InetSocketAddress inetSocketAddress)\n\t\t{\n\t\t\tvar port = inetSocketAddress.getPort();\n\n\t\t\tswitch (type)\n\t\t\t{\n\t\t\t\tcase HOSTNAME ->\n\t\t\t\t{\n\t\t\t\t\tvar hostname = inetSocketAddress.getHostName().getBytes(StandardCharsets.US_ASCII);\n\t\t\t\t\tvar bytes = new byte[hostname.length + 2];\n\t\t\t\t\tSystem.arraycopy(hostname, 0, bytes, 0, hostname.length);\n\t\t\t\t\tbytes[bytes.length - 2] = (byte) (port >> 8);\n\t\t\t\t\tbytes[bytes.length - 1] = (byte) (port & 0xff);\n\t\t\t\t\treturn Optional.of(bytes);\n\t\t\t\t}\n\t\t\t\tcase IPV4 ->\n\t\t\t\t{\n\t\t\t\t\tvar bytes = new byte[6];\n\t\t\t\t\tSystem.arraycopy(inetSocketAddress.getAddress().getAddress(), 0, bytes, 0, 4);\n\t\t\t\t\tbytes[4] = (byte) (port >> 8);\n\t\t\t\t\tbytes[5] = (byte) (port & 0xff);\n\t\t\t\t\treturn Optional.of(bytes);\n\t\t\t\t}\n\t\t\t\tcase null, default -> throw new UnsupportedOperationException(\"Can't get address for type \" + type);\n\t\t\t}\n\t\t}\n\t\telse if (socketAddress instanceof DomainNameSocketAddress)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Can't get the address of a DomainNameSocketAddress as it requires a port\");\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t/**\n\t * Gets the type of the PeerAddress.\n\t *\n\t * @return the type of the PeerAddress\n\t */\n\tpublic Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic String getUrl()\n\t{\n\t\treturn type.scheme() + getAddress().orElseThrow();\n\t}\n\n\t/**\n\t * Checks if the PeerAddress is invalid.\n\t *\n\t * @return true if invalid\n\t */\n\tpublic boolean isInvalid()\n\t{\n\t\treturn type == INVALID;\n\t}\n\n\t/**\n\t * Checks if the PeerAddress is valid.\n\t *\n\t * @return true if valid\n\t */\n\tpublic boolean isValid()\n\t{\n\t\treturn type != INVALID;\n\t}\n\n\t/**\n\t * Checks if the PeerAddress is a hidden address (Tor/I2P)\n\t *\n\t * @return true if the address is a hidden address\n\t */\n\tpublic boolean isHidden()\n\t{\n\t\treturn type == TOR || type == I2P;\n\t}\n\n\t/**\n\t * Checks if the PeerAddress is an external address (that is, something that can be connected to from outside a LAN).\n\t *\n\t * @return true if external address\n\t */\n\tpublic boolean isExternal()\n\t{\n\t\treturn type == TOR || type == I2P ||\n\t\t\t\t(type == IPV4 && IP.isPublicIp(((InetSocketAddress) socketAddress).getHostString()));\n\t}\n\n\tpublic boolean isLAN()\n\t{\n\t\treturn type == IPV4 && IP.isLanIp(((InetSocketAddress) socketAddress).getHostString());\n\t}\n\n\tpublic boolean isHostname()\n\t{\n\t\treturn type == HOSTNAME;\n\t}\n\n\tprivate static boolean isInvalidIpAddress(String address)\n\t{\n\t\tif (address == null)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\n\t\tvar octets = address.split(\"\\\\.\");\n\n\t\tif (octets.length != 4)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\treturn Arrays.stream(octets)\n\t\t\t\t\t.filter(not(s -> s.length() > 1 && s.startsWith(\"0\")))\n\t\t\t\t\t.map(Integer::parseInt)\n\t\t\t\t\t.filter(i -> (i >= 0 && i <= 255))\n\t\t\t\t\t.count() != 4 || !IP.isRoutableIp(address);\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t}\n\n\tprivate static boolean isInvalidHostname(String hostname)\n\t{\n\t\treturn !(hostname != null && hostname.length() <= 253 && HOSTNAME_OK_PATTERN.matcher(hostname).matches());\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"PeerAddress{\" +\n\t\t\t\t\"socketAddress=\" + socketAddress +\n\t\t\t\t\", type=\" + type +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/ControlPoint.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport io.xeres.app.util.XmlUtils;\nimport io.xeres.common.AppName;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.xml.sax.SAXException;\n\nimport javax.xml.parsers.ParserConfigurationException;\nimport javax.xml.xpath.XPathException;\nimport javax.xml.xpath.XPathExpressionException;\nimport javax.xml.xpath.XPathFactory;\nimport javax.xml.xpath.XPathNodes;\nimport java.io.ByteArrayInputStream;\nimport java.io.FileNotFoundException;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.HashMap;\nimport java.util.Locale;\nimport java.util.Map;\n\nfinal class ControlPoint\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ControlPoint.class);\n\n\tprivate ControlPoint()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic boolean updateDevice(DeviceSpecs upnpDevice, URI location)\n\t{\n\t\tvar controlPointFound = false;\n\n\t\ttry\n\t\t{\n\t\t\tvar document = XmlUtils.getSecureDocumentBuilderFactory().newDocumentBuilder().parse(location.toString());\n\t\t\tvar xPath = XPathFactory.newInstance().newXPath();\n\n\t\t\tvar devices = xPath.evaluateExpression(\"//device[deviceType[contains(text(), 'InternetGatewayDevice')]]\", document, XPathNodes.class);\n\n\t\t\tgetDeviceInfo(upnpDevice, devices);\n\n\t\t\tvar services = xPath.evaluateExpression(\"//service[serviceType[contains(text(), 'WANIPConnection') or contains(text(), 'WANPPPConnection')]]\", document, XPathNodes.class);\n\n\t\t\tcontrolPointFound = hasServices(upnpDevice, services);\n\t\t}\n\t\tcatch (FileNotFoundException _)\n\t\t{\n\t\t\tlog.error(\"UPNP router's URL {} is not accessible\", location);\n\t\t}\n\t\tcatch (ParserConfigurationException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't create XML parser for UPNP router URL {}: {}\", location, e.getMessage());\n\t\t}\n\t\tcatch (SAXException e)\n\t\t{\n\t\t\tlog.error(\"XML parse error for UPNP router URL {}: {}\", location, e.getMessage());\n\t\t}\n\t\tcatch (XPathException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"XPath expression error: \" + e.getMessage(), e);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"I/O error when parsing UPNP's router URL {}: {}\", location, e.getMessage());\n\t\t}\n\t\treturn controlPointFound;\n\t}\n\n\tprivate static void getDeviceInfo(DeviceSpecs upnpDevice, XPathNodes devices) throws XPathException\n\t{\n\t\tif (devices.size() != 1)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Require 1 root device, found: \" + devices.size());\n\t\t}\n\n\t\tvar childNodes = devices.get(0).getChildNodes();\n\t\tfor (var i = 0; i < childNodes.getLength(); i++)\n\t\t{\n\t\t\tvar item = childNodes.item(i);\n\t\t\tswitch (item.getNodeName().toLowerCase(Locale.ROOT))\n\t\t\t{\n\t\t\t\tcase \"modelname\" -> upnpDevice.setModelName(item.getTextContent().trim());\n\t\t\t\tcase \"manufacturer\" -> upnpDevice.setManufacturer(item.getTextContent().trim());\n\t\t\t\tcase \"manufacturerurl\" -> upnpDevice.setManufacturerUrl(item.getTextContent().trim());\n\t\t\t\tcase \"serialnumber\" -> upnpDevice.setSerialNumber(item.getTextContent().trim());\n\t\t\t\tcase \"presentationurl\" -> upnpDevice.setPresentationUrl(item.getTextContent().trim());\n\t\t\t\tdefault -> log.trace(\"node: {}\", item.getNodeName());\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static boolean hasServices(DeviceSpecs upnpDevice, XPathNodes services) throws XPathException\n\t{\n\t\tvar controlUrlFound = false;\n\n\t\tif (services.size() != 1)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"More than one service: \" + services.size());\n\t\t}\n\n\t\tvar childNodes = services.get(0).getChildNodes();\n\t\tfor (var i = 0; i < childNodes.getLength(); i++)\n\t\t{\n\t\t\tvar item = childNodes.item(i);\n\t\t\tswitch (item.getNodeName().toLowerCase(Locale.ROOT))\n\t\t\t{\n\t\t\t\tcase \"controlurl\" -> {\n\t\t\t\t\tupnpDevice.setControlUrl(item.getTextContent().trim());\n\t\t\t\t\tcontrolUrlFound = true;\n\t\t\t\t}\n\t\t\t\tcase \"servicetype\" -> upnpDevice.setServiceType(item.getTextContent().trim());\n\t\t\t\tdefault -> log.trace(\"service: {}\", item.getNodeName());\n\t\t\t}\n\t\t}\n\t\treturn controlUrlFound;\n\t}\n\n\tstatic boolean addPortMapping(URI controlUrl, String serviceType, String internalIp, int internalPort, int externalPort, int duration, Protocol protocol)\n\t{\n\t\tMap<String, String> args = HashMap.newHashMap(8);\n\t\targs.put(\"NewRemoteHost\", \"\");\n\t\targs.put(\"NewExternalPort\", String.valueOf(externalPort));\n\t\targs.put(\"NewProtocol\", protocol.name());\n\t\targs.put(\"NewInternalPort\", String.valueOf(internalPort));\n\t\targs.put(\"NewInternalClient\", internalIp);\n\t\targs.put(\"NewEnabled\", \"1\");\n\t\targs.put(\"NewPortMappingDescription\", AppName.NAME + \" \" + protocol.name());\n\t\targs.put(\"NewLeaseDuration\", String.valueOf(duration));\n\n\t\tvar response = Soap.sendRequest(controlUrl, serviceType, \"AddPortMapping\", args);\n\t\treturn response.getStatusCode() == HttpStatus.OK;\n\t}\n\n\tstatic boolean removePortMapping(URI controlUrl, String serviceType, int externalPort, Protocol protocol)\n\t{\n\t\tMap<String, String> args = HashMap.newHashMap(3);\n\t\targs.put(\"NewRemoteHost\", \"\");\n\t\targs.put(\"NewExternalPort\", String.valueOf(externalPort));\n\t\targs.put(\"NewProtocol\", protocol.name());\n\n\t\tvar response = Soap.sendRequest(controlUrl, serviceType, \"DeletePortMapping\", args);\n\t\treturn response.getStatusCode() == HttpStatus.OK;\n\t}\n\n\tstatic String getExternalIpAddress(URI controlUrl, String serviceType)\n\t{\n\t\tvar response = Soap.sendRequest(controlUrl, serviceType, \"GetExternalIPAddress\", null);\n\t\tvar body = response.getBody();\n\t\tif (response.getStatusCode() == HttpStatus.OK && body != null)\n\t\t{\n\t\t\tvar reply = getTextNodes(body);\n\t\t\treturn reply.getOrDefault(\"NewExternalIPAddress\", \"\");\n\t\t}\n\t\treturn \"\";\n\t}\n\n\tstatic Map<String, String> getTextNodes(String xml)\n\t{\n\t\tMap<String, String> result = new HashMap<>();\n\t\ttry\n\t\t{\n\t\t\tvar document = XmlUtils.getSecureDocumentBuilderFactory().newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes()));\n\t\t\tvar xPath = XPathFactory.newInstance().newXPath();\n\t\t\tvar textNodes = xPath.evaluateExpression(\"//text()\", document, XPathNodes.class);\n\n\t\t\tfor (var textNode : textNodes)\n\t\t\t{\n\t\t\t\tresult.put(textNode.getParentNode().getNodeName(), textNode.getTextContent());\n\t\t\t}\n\t\t}\n\t\tcatch (SAXException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"XML parse error on UPNP router reply: \" + e.getMessage(), e);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"I/O error when parsing UPNP router's XML reply: \" + e.getMessage(), e);\n\t\t}\n\t\tcatch (ParserConfigurationException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Couldn't create XML parser for UPNP router's XML reply: \" + e.getMessage(), e);\n\t\t}\n\t\tcatch (XPathExpressionException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"XPath expression error: \" + e.getMessage(), e);\n\t\t}\n\t\treturn result;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/Device.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.BufferedReader;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.net.InetSocketAddress;\nimport java.net.SocketAddress;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nfinal class Device implements DeviceSpecs\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Device.class);\n\n\tprivate static final Pattern HTTP_OK_PATTERN = Pattern.compile(\"^HTTP/1\\\\.. 200 OK\");\n\tprivate static final int MAX_HEADER_VALUE_LENGTH = 128;\n\n\tprivate static final Set<HttpuHeader> supportedHeaders = EnumSet.allOf(HttpuHeader.class);\n\n\tprivate InetSocketAddress inetSocketAddress;\n\tprivate Map<HttpuHeader, String> headers;\n\n\tprivate String modelName;\n\tprivate String manufacturer;\n\tprivate URI manufacturerUrl;\n\tprivate String serialNumber;\n\tprivate URI presentationUrl;\n\tprivate URI controlUrl;\n\tprivate URI locationUrl;\n\tprivate String serviceType;\n\tprivate boolean hasControlPoint;\n\tprivate final HashSet<PortMapping> ports = new HashSet<>();\n\n\tstatic Device from(SocketAddress socketAddress, ByteBuffer byteBuffer)\n\t{\n\t\tif (!(socketAddress instanceof InetSocketAddress))\n\t\t{\n\t\t\tlog.warn(\"Not an Inet device. Ignoring.\");\n\t\t\treturn Device.fromInvalid();\n\t\t}\n\n\t\tvar reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(byteBuffer.array()), StandardCharsets.US_ASCII));\n\n\t\ttry\n\t\t{\n\t\t\tMap<HttpuHeader, String> headers = new EnumMap<>(HttpuHeader.class);\n\t\t\tvar s = reader.readLine();\n\n\t\t\tif (!HTTP_OK_PATTERN.matcher(s).matches())\n\t\t\t{\n\t\t\t\tlog.warn(\"Not a valid HTTP response: {}. Ignoring.\", s);\n\t\t\t\treturn Device.fromInvalid();\n\t\t\t}\n\n\t\t\twhile ((s = reader.readLine()) != null)\n\t\t\t{\n\t\t\t\tvar tokens = s.split(\":\", 2);\n\t\t\t\tif (tokens.length != 2 || tokens[1].length() > MAX_HEADER_VALUE_LENGTH)\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tvar header = tokens[0].toUpperCase(Locale.ROOT).strip();\n\t\t\t\tif (supportedHeaders.stream().anyMatch(h -> h.name().equals(header)))\n\t\t\t\t{\n\t\t\t\t\theaders.put(HttpuHeader.valueOf(header), tokens[1].strip());\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn new Device(socketAddress, headers);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.warn(\"Couldn't read line, shouldn't happen\", e);\n\t\t}\n\t\treturn Device.fromInvalid();\n\t}\n\n\tprivate static Device fromInvalid()\n\t{\n\t\treturn new Device();\n\t}\n\n\tprivate Device(SocketAddress socketAddress, Map<HttpuHeader, String> headers)\n\t{\n\t\tinetSocketAddress = (InetSocketAddress) socketAddress;\n\t\tthis.headers = headers;\n\t}\n\n\tprivate Device()\n\t{\n\n\t}\n\n\tpublic boolean isValid()\n\t{\n\t\treturn inetSocketAddress != null && hasLocation();\n\t}\n\n\tpublic boolean isInvalid()\n\t{\n\t\treturn inetSocketAddress == null;\n\t}\n\n\tpublic InetSocketAddress getInetSocketAddress()\n\t{\n\t\treturn inetSocketAddress;\n\t}\n\n\tpublic Optional<String> getHeaderValue(HttpuHeader header)\n\t{\n\t\tif (isInvalid())\n\t\t{\n\t\t\treturn Optional.empty();\n\t\t}\n\t\treturn Optional.ofNullable(headers.get(header));\n\t}\n\n\tpublic boolean hasLocation()\n\t{\n\t\treturn getLocationUrl() != null;\n\t}\n\n\tpublic URI getLocationUrl()\n\t{\n\t\tif (locationUrl != null)\n\t\t{\n\t\t\treturn locationUrl;\n\t\t}\n\n\t\tlocationUrl = getHeaderValue(HttpuHeader.LOCATION).map(s -> {\n\t\t\ttry\n\t\t\t{\n\t\t\t\treturn new URI(s);\n\t\t\t}\n\t\t\tcatch (URISyntaxException e)\n\t\t\t{\n\t\t\t\tlog.error(\"UPNP: unparseable URL {}, {}\", s, e.getMessage());\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}).orElse(null);\n\t\treturn locationUrl;\n\t}\n\n\tpublic boolean hasServer()\n\t{\n\t\treturn getHeaderValue(HttpuHeader.SERVER).isPresent();\n\t}\n\n\tpublic String getServer()\n\t{\n\t\treturn getHeaderValue(HttpuHeader.SERVER).orElse(null);\n\t}\n\n\tpublic boolean hasUsn()\n\t{\n\t\treturn getHeaderValue(HttpuHeader.USN).isPresent();\n\t}\n\n\tpublic String getUsn()\n\t{\n\t\treturn getHeaderValue(HttpuHeader.USN).orElse(null);\n\t}\n\n\t@Override\n\tpublic boolean hasModelName()\n\t{\n\t\treturn modelName != null;\n\t}\n\n\t@Override\n\tpublic String getModelName()\n\t{\n\t\treturn modelName;\n\t}\n\n\t@Override\n\tpublic void setModelName(String modelName)\n\t{\n\t\tthis.modelName = modelName;\n\t}\n\n\t@Override\n\tpublic boolean hasManufacturer()\n\t{\n\t\treturn manufacturer != null;\n\t}\n\n\t@Override\n\tpublic String getManufacturer()\n\t{\n\t\treturn manufacturer;\n\t}\n\n\t@Override\n\tpublic void setManufacturer(String manufacturer)\n\t{\n\t\tthis.manufacturer = manufacturer;\n\t}\n\n\t@Override\n\tpublic URI getManufacturerUrl()\n\t{\n\t\treturn manufacturerUrl;\n\t}\n\n\t@Override\n\tpublic void setManufacturerUrl(String manufacturerUrl)\n\t{\n\t\tthis.manufacturerUrl = parseUrl(manufacturerUrl);\n\t}\n\n\t@Override\n\tpublic boolean hasSerialNumber()\n\t{\n\t\treturn serialNumber != null;\n\t}\n\n\t@Override\n\tpublic String getSerialNumber()\n\t{\n\t\treturn serialNumber;\n\t}\n\n\t@Override\n\tpublic void setSerialNumber(String serialNumber)\n\t{\n\t\tthis.serialNumber = serialNumber;\n\t}\n\n\t@Override\n\tpublic boolean hasControlUrl()\n\t{\n\t\treturn controlUrl != null;\n\t}\n\n\t@Override\n\tpublic URI getControlUrl()\n\t{\n\t\treturn controlUrl;\n\t}\n\n\t@Override\n\tpublic void setControlUrl(String controlUrl)\n\t{\n\t\tthis.controlUrl = parseUrl(locationUrl, controlUrl);\n\t}\n\n\t@Override\n\tpublic boolean hasPresentationUrl()\n\t{\n\t\treturn presentationUrl != null;\n\t}\n\n\t@Override\n\tpublic URI getPresentationUrl()\n\t{\n\t\treturn presentationUrl;\n\t}\n\n\t@Override\n\tpublic void setPresentationUrl(String presentationUrl)\n\t{\n\t\tthis.presentationUrl = parseUrl(presentationUrl);\n\t}\n\n\t@Override\n\tpublic String getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\t@Override\n\tpublic void setServiceType(String serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\tpublic void addControlPoint()\n\t{\n\t\tif (isInvalid())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Trying to add a control point to an invalid device\");\n\t\t}\n\t\thasControlPoint = ControlPoint.updateDevice(this, getLocationUrl());\n\t}\n\n\tpublic boolean hasControlPoint()\n\t{\n\t\treturn hasControlPoint;\n\t}\n\n\tpublic boolean addPortMapping(String internalIp, int internalPort, int externalPort, int duration, Protocol protocol)\n\t{\n\t\tvar added = ControlPoint.addPortMapping(getControlUrl(), getServiceType(), internalIp, internalPort, externalPort, duration, protocol);\n\t\tif (added)\n\t\t{\n\t\t\tports.add(new PortMapping(externalPort, protocol));\n\t\t}\n\t\treturn added;\n\t}\n\n\tpublic void deletePortMapping(int externalPort, Protocol protocol)\n\t{\n\t\tif (ControlPoint.removePortMapping(getControlUrl(), getServiceType(), externalPort, protocol))\n\t\t{\n\t\t\tports.removeIf(portMapping -> portMapping.port() == externalPort && portMapping.protocol() == protocol);\n\t\t}\n\t}\n\n\tpublic void removeAllPortMapping()\n\t{\n\t\tnew HashSet<>(ports).forEach(portMapping -> deletePortMapping(portMapping.port(), portMapping.protocol()));\n\t}\n\n\tpublic String getExternalIpAddress()\n\t{\n\t\treturn ControlPoint.getExternalIpAddress(getControlUrl(), getServiceType());\n\t}\n\n\tprivate static URI parseUrl(String url)\n\t{\n\t\treturn parseUrl(null, url);\n\t}\n\n\tprivate static URI parseUrl(URI baseUrl, String url)\n\t{\n\t\ttry\n\t\t{\n\t\t\tif (baseUrl != null)\n\t\t\t{\n\t\t\t\treturn baseUrl.resolve(url);\n\t\t\t}\n\t\t\treturn new URI(addProtocolIfMissing(url));\n\t\t}\n\t\tcatch (URISyntaxException e)\n\t\t{\n\t\t\tlog.error(\"Wrong URL {}, {}\", url, e.getMessage());\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Fixes the URL returned by some routers that miss a protocol, for\n\t * example www.Nucom.com\n\t * @param url the url\n\t * @return a url with the protocol prepended\n\t */\n\tprivate static String addProtocolIfMissing(String url)\n\t{\n\t\tif (url != null && url.toLowerCase(Locale.ROOT).startsWith(\"www.\"))\n\t\t{\n\t\t\treturn \"https://\" + url;\n\t\t}\n\t\treturn url;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Device{\" +\n\t\t\t\t\"inetSocketAddress=\" + inetSocketAddress +\n\t\t\t\t\", headers=\" + headers +\n\t\t\t\t\", modelName='\" + modelName + '\\'' +\n\t\t\t\t\", manufacturer='\" + manufacturer + '\\'' +\n\t\t\t\t\", manufacturerUrl=\" + manufacturerUrl +\n\t\t\t\t\", serialNumber='\" + serialNumber + '\\'' +\n\t\t\t\t\", presentationUrl=\" + presentationUrl +\n\t\t\t\t\", controlUrl=\" + controlUrl +\n\t\t\t\t\", locationUrl=\" + locationUrl +\n\t\t\t\t\", serviceType='\" + serviceType + '\\'' +\n\t\t\t\t\", hasControlPoint=\" + hasControlPoint +\n\t\t\t\t\", ports=\" + ports +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/DeviceSpecs.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport java.net.URI;\n\npublic interface DeviceSpecs\n{\n\tboolean hasModelName();\n\n\tString getModelName();\n\n\tvoid setModelName(String modelName);\n\n\tboolean hasManufacturer();\n\n\tString getManufacturer();\n\n\tvoid setManufacturer(String manufacturer);\n\n\tURI getManufacturerUrl();\n\n\tvoid setManufacturerUrl(String manufacturerUrl);\n\n\tboolean hasSerialNumber();\n\n\tString getSerialNumber();\n\n\tvoid setSerialNumber(String serialNumber);\n\n\tboolean hasControlUrl();\n\n\tURI getControlUrl();\n\n\tvoid setControlUrl(String controlUrl);\n\n\tboolean hasPresentationUrl();\n\n\tURI getPresentationUrl();\n\n\tvoid setPresentationUrl(String presentationUrl);\n\n\tString getServiceType();\n\n\tvoid setServiceType(String serviceType);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/HttpuHeader.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nenum HttpuHeader\n{\n\tLOCATION,\n\tSERVER,\n\tST,\n\tUSN\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/PortMapping.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nrecord PortMapping(int port, Protocol protocol)\n{\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (PortMapping) o;\n\t\treturn port == that.port &&\n\t\t\t\tprotocol == that.protocol;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/Protocol.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nenum Protocol\n{\n\tTCP,\n\tUDP\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/Soap.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport org.springframework.web.reactive.function.client.WebClientException;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.Map;\n\nfinal class Soap\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Soap.class);\n\n\tprivate Soap()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static String createSoap(String serviceType, String actionName, Map<String, String> args)\n\t{\n\t\tvar soap = new StringBuilder();\n\n\t\tsoap.append(\"<?xml version=\\\"1.0\\\"?>\\r\\n\");\n\t\tsoap.append(\"<s:Envelope xmlns:s=\\\"http://schemas.xmlsoap.org/soap/envelope/\\\" s:encodingStyle=\\\"http://schemas.xmlsoap.org/soap/encoding/\\\">\");\n\t\tsoap.append(\"<s:Body>\");\n\t\tsoap.append(\"<u:\").append(actionName).append(\" xmlns:u=\\\"\").append(serviceType).append(\"\\\">\");\n\n\t\tif (args != null)\n\t\t{\n\t\t\targs.forEach((key, value) -> soap.append(\"<\").append(key).append(\">\").append(value).append(\"</\").append(key).append(\">\"));\n\t\t}\n\n\t\tsoap.append(\"</u:\").append(actionName).append(\">\");\n\t\tsoap.append(\"</s:Body>\");\n\t\tsoap.append(\"</s:Envelope>\");\n\n\t\treturn soap.toString();\n\t}\n\n\tstatic ResponseEntity<String> sendRequest(URI controlUrl, String serviceType, String action, Map<String, String> args)\n\t{\n\t\tvar webClient = WebClient.builder()\n\t\t\t\t.baseUrl(controlUrl.toString())\n\t\t\t\t.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_XML_VALUE)\n\t\t\t\t.build();\n\n\t\ttry\n\t\t{\n\t\t\treturn webClient.post()\n\t\t\t\t\t.bodyValue(createSoap(serviceType, action, args))\n\t\t\t\t\t.header(\"SOAPAction\", \"\\\"\" + serviceType + \"#\" + action + \"\\\"\")\n\t\t\t\t\t.retrieve()\n\t\t\t\t\t.toEntity(String.class)\n\t\t\t\t\t.block(Duration.ofSeconds(10));\n\t\t}\n\t\tcatch (WebClientException e)\n\t\t{\n\t\t\tlog.error(\"Bad request: {}\", e.getMessage());\n\t\t\treturn ResponseEntity.badRequest().build();\n\t\t}\n\t\tcatch (RuntimeException e)\n\t\t{\n\t\t\tlog.error(\"Timeout while sending request: {}\", e.getMessage());\n\t\t\treturn ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).build();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/UPNPService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport io.xeres.app.application.events.UpnpEvent;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.external.ExternalIpResolver;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.common.rest.notification.status.NatStatus;\nimport io.xeres.common.util.ThreadUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.nio.ByteBuffer;\nimport java.nio.channels.ClosedByInterruptException;\nimport java.nio.channels.DatagramChannel;\nimport java.nio.channels.SelectionKey;\nimport java.nio.channels.Selector;\nimport java.time.Duration;\n\n@Service\npublic class UPNPService implements Runnable\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UPNPService.class);\n\n\tprivate static final String MULTICAST_IP = \"239.255.255.250\";\n\tprivate static final int MULTICAST_PORT = 1900;\n\tprivate static final int MULTICAST_BUFFER_SEND_SIZE_MAX = 512; // this is the maximum size used by MiniUPNPd so better not use more\n\tprivate static final int MULTICAST_BUFFER_RECV_SIZE = 1024; // also used by MiniUPNPd\n\n\tprivate static final int MULTICAST_MAX_WAIT_TIME = (int) Duration.ofSeconds(3).toMillis(); // time to wait for a router reply\n\tprivate static final int MULTICAST_MAX_WAIT_SNOOZE = (int) Duration.ofMinutes(5).toMillis(); // time to wait if nothing has answered all requests\n\tprivate static final int MULTICAST_DELAY_HINT = (int) Duration.ofSeconds(1).toSeconds(); // how long a router can delay its reply\n\n\tprivate static final int PORT_DURATION = (int) Duration.ofHours(1).toMillis(); // how long does a port mapping lasts\n\tprivate static final int PORT_DURATION_ANTICIPATION = (int) Duration.ofMinutes(1).toMillis(); // when to kick in the refresh before it expires\n\n\tprivate static final Duration SERVICE_RETRY_DURATION = Duration.ofMinutes(5); // time to retry the service when getting an error\n\n\tprivate static final String[] DEVICES = {\n\t\t\t// IGD 1\n\t\t\t\"urn:schemas-upnp-org:device:InternetGatewayDevice:1\",\n\t\t\t\"urn:schemas-upnp-org:service:WANIPConnection:1\",\n\t\t\t\"urn:schemas-upnp-org:device:WANDevice:1\",\n\t\t\t\"urn:schemas-upnp-org:device:WANConnectionDevice:1\",\n\t\t\t\"urn:schemas-upnp-org:service:WANPPPConnection:1\",\n\t\t\t// IGD 2\n\t\t\t\"urn:schemas-upnp-org:device:InternetGatewayDevice:2\",\n\t\t\t\"urn:schemas-upnp-org:device:WANDevice:2\",\n\t\t\t\"urn:schemas-upnp-org:device:WANConnectionDevice:2\",\n\t\t\t\"urn:schemas-upnp-org:service:WANIPConnection:2\",\n\t\t\t// Most routers will respond to all entries\n\t};\n\n\tprivate enum State\n\t{\n\t\tSNOOZING,\n\t\tBROADCASTING,\n\t\tWAITING,\n\t\tCONNECTING,\n\t\tCONNECTED,\n\t\tINTERRUPTED\n\t}\n\n\tprivate final LocationService locationService;\n\tprivate final ApplicationEventPublisher publisher;\n\tprivate final StatusNotificationService statusNotificationService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ExternalIpResolver externalIpResolver;\n\n\tprivate int deviceIndex;\n\n\tprivate String localIpAddress;\n\tprivate int localPort;\n\tprivate int controlPort;\n\tprivate Thread thread;\n\n\tprivate SocketAddress multicastAddress;\n\tprivate ByteBuffer sendBuffer;\n\tprivate ByteBuffer receiveBuffer;\n\tprivate State state;\n\tprivate Device device;\n\tprivate boolean externalIpAddressFound;\n\n\tpublic UPNPService(LocationService locationService, ApplicationEventPublisher publisher, StatusNotificationService statusNotificationService, DatabaseSessionManager databaseSessionManager, ExternalIpResolver externalIpResolver)\n\t{\n\t\tthis.locationService = locationService;\n\t\tthis.publisher = publisher;\n\t\tthis.statusNotificationService = statusNotificationService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.externalIpResolver = externalIpResolver;\n\t}\n\n\tpublic void start(String localIpAddress, int localPort, int controlPort)\n\t{\n\t\tlog.info(\"Starting UPNP service...\");\n\t\tthis.localIpAddress = localIpAddress;\n\t\tthis.localPort = localPort;\n\t\tthis.controlPort = controlPort;\n\n\t\tstatusNotificationService.setNatStatus(NatStatus.UNKNOWN);\n\n\t\tthread = Thread.ofVirtual()\n\t\t\t\t.name(\"UPNP Service\")\n\t\t\t\t.start(this);\n\t}\n\n\tpublic void stop()\n\t{\n\t\tif (thread != null)\n\t\t{\n\t\t\tlog.info(\"Stopping UPNP...\");\n\t\t\tthread.interrupt();\n\t\t}\n\n\t\tstatusNotificationService.setNatStatus(NatStatus.UNKNOWN);\n\t}\n\n\tpublic boolean isRunning()\n\t{\n\t\treturn thread.isAlive();\n\t}\n\n\tpublic void waitForTermination()\n\t{\n\t\tThreadUtils.waitForThread(thread);\n\t}\n\n\tprivate static String getMSearch(String device)\n\t{\n\t\treturn \"M-SEARCH * HTTP/1.1\\r\\nHost: \" + MULTICAST_IP + \":\" + MULTICAST_PORT + \"\\r\\nST: \" + device + \"\\r\\nMan: \\\"ssdp:discover\\\"\\r\\nMX: \" + MULTICAST_DELAY_HINT + \"\\r\\n\\r\\n\";\n\t}\n\n\tprivate void getUpnpDeviceSearch(SelectionKey selectionKey)\n\t{\n\t\tsendBuffer = ByteBuffer.wrap(getMSearch(DEVICES[deviceIndex % DEVICES.length]).getBytes());\n\t\tif (sendBuffer.limit() > MULTICAST_BUFFER_SEND_SIZE_MAX)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Send buffer bigger than \" + MULTICAST_BUFFER_SEND_SIZE_MAX + \" (\" + sendBuffer.limit() + \")\");\n\t\t}\n\t\tdeviceIndex++;\n\t\tif (deviceIndex > 0 && deviceIndex % DEVICES.length == 0)\n\t\t{\n\t\t\tsetState(State.SNOOZING, selectionKey);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void run()\n\t{\n\t\twhile (true)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tupnpLoop();\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcatch (BindException e)\n\t\t\t{\n\t\t\t\tlog.warn(\"Binding failed: {}, trying again in 5 minutes\", e.getMessage());\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tThread.sleep(SERVICE_RETRY_DURATION);\n\t\t\t\t}\n\t\t\t\tcatch (InterruptedException _)\n\t\t\t\t{\n\t\t\t\t\tThread.currentThread().interrupt();\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void upnpLoop() throws BindException\n\t{\n\t\tmulticastAddress = new InetSocketAddress(MULTICAST_IP, MULTICAST_PORT);\n\t\treceiveBuffer = ByteBuffer.allocate(MULTICAST_BUFFER_RECV_SIZE);\n\n\t\ttry (var selector = Selector.open();\n\t\t     var channel = DatagramChannel.open(StandardProtocolFamily.INET)\n\t\t\t\t     .bind(new InetSocketAddress(InetAddress.getByName(localIpAddress), 0))\n\t\t)\n\t\t{\n\t\t\tchannel.configureBlocking(false);\n\t\t\tvar registerSelectionKeys = channel.register(selector, SelectionKey.OP_WRITE);\n\t\t\tstate = State.BROADCASTING;\n\n\t\t\twhile (true)\n\t\t\t{\n\t\t\t\tif (state == State.BROADCASTING)\n\t\t\t\t{\n\t\t\t\t\tgetUpnpDeviceSearch(registerSelectionKeys);\n\t\t\t\t}\n\n\t\t\t\tif (state == State.SNOOZING)\n\t\t\t\t{\n\t\t\t\t\tattemptFindExternalAddressUsingDnsIfNeeded();\n\t\t\t\t}\n\n\t\t\t\tselector.select(getSelectorTimeout());\n\t\t\t\tif (Thread.interrupted())\n\t\t\t\t{\n\t\t\t\t\tsetState(State.INTERRUPTED, registerSelectionKeys);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (state == State.CONNECTED)\n\t\t\t\t{\n\t\t\t\t\tvar refreshed = refreshPorts();\n\t\t\t\t\tif (refreshed)\n\t\t\t\t\t{\n\t\t\t\t\t\tstatusNotificationService.setNatStatus(NatStatus.UPNP);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tlog.error(\"UPNP port refresh failed, starting again...\");\n\t\t\t\t\t\tstatusNotificationService.setNatStatus(NatStatus.FIREWALLED);\n\t\t\t\t\t\tsetState(State.BROADCASTING, registerSelectionKeys);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thandleSelection(selector, registerSelectionKeys);\n\t\t\t}\n\t\t\tcleanupDevice();\n\t\t}\n\t\tcatch (ClosedByInterruptException _)\n\t\t{\n\t\t\tlog.debug(\"Interrupted, bailing out...\");\n\t\t}\n\t\tcatch (BindException e)\n\t\t{\n\t\t\tthrow e;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Error: \", e);\n\t\t}\n\t}\n\n\tprivate void handleSelection(Selector selector, SelectionKey registerSelectionKeys) throws BindException\n\t{\n\t\tvar selectedKeys = selector.selectedKeys().iterator();\n\t\tif (!selectedKeys.hasNext() && state != State.CONNECTED)\n\t\t{\n\t\t\tsetState(State.BROADCASTING, registerSelectionKeys);\n\t\t}\n\t\twhile (selectedKeys.hasNext())\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar key = selectedKeys.next();\n\t\t\t\tselectedKeys.remove();\n\n\t\t\t\tif (!key.isValid())\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (key.isReadable())\n\t\t\t\t{\n\t\t\t\t\tread(key);\n\t\t\t\t}\n\t\t\t\telse if (key.isWritable())\n\t\t\t\t{\n\t\t\t\t\twrite(key);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (BindException e)\n\t\t\t{\n\t\t\t\tthrow e;\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.warn(\"Glitch, continuing...\", e);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate int getSelectorTimeout()\n\t{\n\t\treturn switch (state)\n\t\t{\n\t\t\tcase WAITING -> MULTICAST_MAX_WAIT_TIME;\n\t\t\tcase SNOOZING -> MULTICAST_MAX_WAIT_SNOOZE;\n\t\t\tcase CONNECTED -> PORT_DURATION - PORT_DURATION_ANTICIPATION;\n\t\t\tdefault -> 0;\n\t\t};\n\t}\n\n\tprivate void setState(State newState, SelectionKey key)\n\t{\n\t\tstate = newState;\n\n\t\tswitch (state)\n\t\t{\n\t\t\tcase BROADCASTING -> key.interestOps(SelectionKey.OP_WRITE);\n\t\t\tcase WAITING -> key.interestOps(SelectionKey.OP_READ);\n\t\t\tcase CONNECTING, CONNECTED, SNOOZING -> key.interestOps(0);\n\t\t\tcase INTERRUPTED -> log.debug(\"Interrupted\");\n\t\t}\n\t}\n\n\tprivate void read(SelectionKey key) throws IOException\n\t{\n\t\tassert state == State.WAITING;\n\n\t\t@SuppressWarnings(\"resource\") var channel = (DatagramChannel) key.channel();\n\t\tvar routerAddress = channel.receive(receiveBuffer); // XXX: handle multiple responses if there's several routers. use 'rootdevice' to test\n\t\tdevice = Device.from(routerAddress, receiveBuffer);\n\t\tif (device.isValid())\n\t\t{\n\t\t\tsetState(State.CONNECTING, key);\n\t\t\tdevice.addControlPoint();\n\n\t\t\tif (device.hasControlPoint())\n\t\t\t{\n\t\t\t\tsetState(State.CONNECTED, key);\n\t\t\t\tvar portsAdded = refreshPorts();\n\t\t\t\tvar externalAddressFound = findExternalIpAddressUsingUpnp();\n\t\t\t\tif (!externalAddressFound)\n\t\t\t\t{\n\t\t\t\t\texternalAddressFound = findExternalIpAddressUsingDns();\n\t\t\t\t}\n\n\t\t\t\tpublisher.publishEvent(new UpnpEvent(localPort, portsAdded, externalAddressFound));\n\t\t\t\tstatusNotificationService.setNatStatus(portsAdded ? NatStatus.UPNP : NatStatus.FIREWALLED);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// Device has no control point, or it's unreachable; keep searching\n\t\t\t\tsetState(State.WAITING, key);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Device has no location or address, keep searching\n\t\t\tsetState(State.WAITING, key);\n\t\t}\n\n\t\t// XXX: a device must be blacklisted for a while if the above 2 steps fail for it, otherwise we'll run into it again if the user has a broken router on the same LAN\n\t\treceiveBuffer.clear(); // ready to read again\n\t}\n\n\tprivate void write(SelectionKey key) throws IOException\n\t{\n\t\tassert state == State.BROADCASTING;\n\n\t\t@SuppressWarnings(\"resource\") var channel = (DatagramChannel) key.channel();\n\t\tchannel.send(sendBuffer, multicastAddress);\n\t\tsetState(State.WAITING, key);\n\t\tsendBuffer.clear();\n\t}\n\n\tprivate boolean refreshPorts()\n\t{\n\t\t// XXX: add a mechanism if the localport is already taken on the router?\n\t\tvar refreshed = device.addPortMapping(localIpAddress, localPort, localPort, PORT_DURATION / 1000, Protocol.TCP);\n\t\trefreshed &= device.addPortMapping(localIpAddress, localPort, localPort, PORT_DURATION / 1000, Protocol.UDP);\n\t\tif (controlPort != 0)\n\t\t{\n\t\t\trefreshed &= device.addPortMapping(localIpAddress, controlPort, controlPort, PORT_DURATION / 1000, Protocol.TCP);\n\t\t}\n\n\t\tif (refreshed)\n\t\t{\n\t\t\tlog.info(\"UPNP ports added/refreshed successfully.\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.warn(\"Failed to add/refresh UPNP ports. Incoming connections won't be accepted.\");\n\t\t}\n\n\t\treturn refreshed;\n\t}\n\n\tprivate void cleanupDevice()\n\t{\n\t\tif (device != null && device.hasControlPoint())\n\t\t{\n\t\t\tdevice.removeAllPortMapping();\n\t\t}\n\t}\n\n\tprivate void attemptFindExternalAddressUsingDnsIfNeeded()\n\t{\n\t\t// If no UPNP seems available after the first try, attempt\n\t\t// to find the external address using OpenDNS then keep trying\n\t\t// with UPNP (even though it's unlikely to work). This allows at least\n\t\t// to have the IP in the ShortInvite, which is important for reachability.\n\t\tif (!externalIpAddressFound && findExternalIpAddressUsingDns())\n\t\t{\n\t\t\texternalIpAddressFound = true;\n\t\t\tpublisher.publishEvent(new UpnpEvent(localPort, false, true));\n\t\t\tstatusNotificationService.setNatStatus(NatStatus.FIREWALLED);\n\t\t}\n\t}\n\n\tprivate boolean findExternalIpAddressUsingUpnp()\n\t{\n\t\treturn updateExternalIpAddress(device.getExternalIpAddress());\n\t}\n\n\tprivate boolean findExternalIpAddressUsingDns()\n\t{\n\t\tvar externalIpAddress = externalIpResolver.find();\n\t\tif (externalIpAddress == null)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\treturn updateExternalIpAddress(externalIpAddress);\n\t}\n\n\tprivate boolean updateExternalIpAddress(String externalIpAddress)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar peerAddress = PeerAddress.from(externalIpAddress, localPort);\n\t\t\tif (peerAddress.isInvalid())\n\t\t\t{\n\t\t\t\tlog.warn(\"External IP is invalid: {}\", externalIpAddress);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (!peerAddress.isExternal())\n\t\t\t{\n\t\t\t\tlog.warn(\"External IP is not external: {}\", externalIpAddress);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tlocationService.updateConnection(locationService.findOwnLocation().orElseThrow(), peerAddress);\n\t\t\treturn true;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/upnp/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * UPNP implementation.\n * <p>\n * This is a limited UPNP implementation that finds an active router on the network and sets the\n * proper port forwarding. There is no active listening capabilities (for example, detecting if some new device was turned on)\n * because the use cases for it are limited, and it directly clashes with the OS (for example Windows\n * is already listening on port 1900). Using the OS' UPNP stack would require the use of JNI on Windows, Linux\n * has too many possible setups and OSX is unknown.\n * <p>\n * The goal of this implementation is to be fast and useful in 99% of cases.\n * <p>\n * Theory of operation:\n * <ul>\n *     <li>UPNPService launches a thread</li>\n *     <li>the thread broadcasts a MSEARCH HTTPu query as multicast on port 1900</li>\n *     <li>a router answers with its location URL</li>\n *     <li>the thread connects to the control point and retrieves the control point URL which is described in an XML file</li>\n *     <li>further commands (add mapping, removing mapping, get external ip address) are sent to that control point URL using SOAP</li>\n * </ul>\n */\npackage io.xeres.app.net.upnp;\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/net/util/NetworkMode.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.util;\n\npublic enum NetworkMode\n{\n\tPUBLIC, // DHT & Discovery\n\tPRIVATE, // Discovery only\n\tINVERTED, // DHT only\n\tDARKNET; // None\n\n\tpublic static boolean isDiscoverable(NetworkMode networkMode)\n\t{\n\t\treturn switch (networkMode)\n\t\t\t\t{\n\t\t\t\t\tcase PUBLIC, PRIVATE -> true;\n\t\t\t\t\tcase INVERTED, DARKNET -> false;\n\t\t\t\t};\n\t}\n\n\tpublic static boolean hasDht(NetworkMode networkMode)\n\t{\n\t\treturn switch (networkMode)\n\t\t\t\t{\n\t\t\t\t\tcase PUBLIC, INVERTED -> true;\n\t\t\t\t\tcase PRIVATE, DARKNET -> false;\n\t\t\t\t};\n\t}\n\n\tpublic static NetworkMode getNetworkMode(int vsDisc, int vsDht)\n\t{\n\t\tif (vsDisc == 2 && vsDht == 2)\n\t\t{\n\t\t\treturn PUBLIC;\n\t\t}\n\t\telse if (vsDisc == 2)\n\t\t{\n\t\t\treturn PRIVATE;\n\t\t}\n\t\telse if (vsDht == 2)\n\t\t{\n\t\t\treturn INVERTED;\n\t\t}\n\t\treturn DARKNET;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/package-info.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Server part.\n * <p>Uses Spring Boot and a REST API.\n */\npackage io.xeres.app;"
  },
  {
    "path": "app/src/main/java/io/xeres/app/properties/DatabaseProperties.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.properties;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.context.annotation.Configuration;\n\n@Configuration\n@ConfigurationProperties(prefix = \"xrs.db\")\npublic class DatabaseProperties\n{\n\tprivate Integer cacheSize;\n\tprivate Integer maxCompactTime;\n\n\tpublic Integer getCacheSize()\n\t{\n\t\treturn cacheSize;\n\t}\n\n\tpublic void setCacheSize(Integer cacheSize)\n\t{\n\t\tthis.cacheSize = cacheSize;\n\t}\n\n\tpublic Integer getMaxCompactTime()\n\t{\n\t\treturn maxCompactTime;\n\t}\n\n\tpublic void setMaxCompactTime(Integer maxCompactTime)\n\t{\n\t\tthis.maxCompactTime = maxCompactTime;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/properties/NetworkProperties.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.properties;\n\nimport jakarta.annotation.PostConstruct;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.jmx.export.annotation.ManagedAttribute;\nimport org.springframework.jmx.export.annotation.ManagedResource;\n\n@Configuration\n@ConfigurationProperties(prefix = \"xrs.network\")\n@ManagedResource(objectName = \"io.xeres:type=NetworkProperties\", description = \"Shows the network configuration\")\npublic class NetworkProperties\n{\n\t/**\n\t * Enables the slicing of packets. This is only available on new Retroshare packets and only if both ends\n\t * of the connection agree to use them. Note that Xeres always accepts sliced packets.\n\t */\n\tprivate boolean packetSlicing;\n\n\t/**\n\t * Enables the grouping of packets. Only works if packet slicing is enabled.\n\t */\n\tprivate boolean packetGrouping;\n\n\t/**\n\t * Sets the encrypted tunnel format.\n\t * <ul>\n\t *     <li>ChaCha20 with HMAC SHA-256 {@code \"chacha20-sha256\"}: the default of Retroshare</li>\n\t *     <li>ChaCha20 with Poly1305 authenticator {@code \"chacha20-poly1305\"}: should be accepted by Retroshare, but untested</li>\n\t * </ul>\n\t */\n\tprivate String tunnelEncryption = TUNNEL_ENCRYPTION_CHACHA20_SHA256;\n\tpublic static final String TUNNEL_ENCRYPTION_CHACHA20_SHA256 = \"chacha20-sha256\";\n\tpublic static final String TUNNEL_ENCRYPTION_CHACHA20_POLY1305 = \"chacha20-poly1305\";\n\n\tprivate String fileTransferStrategy = FILE_TRANSFER_STRATEGY_LINEAR;\n\tpublic static final String FILE_TRANSFER_STRATEGY_LINEAR = \"linear\";\n\tpublic static final String FILE_TRANSFER_STRATEGY_RANDOM = \"random\";\n\n\t@PostConstruct\n\tprivate void checkConsistency()\n\t{\n\t\tif (packetGrouping && !packetSlicing)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"'network.packet-grouping' property cannot be enabled without 'network.packet-slicing'\");\n\t\t}\n\t}\n\n\tpublic String getFeatures()\n\t{\n\t\treturn \"packet slicing: \" + packetSlicing + \", \" +\n\t\t\t\t\"packet grouping: \" + packetGrouping;\n\t}\n\n\t@ManagedAttribute(description = \"If the packet slicing is enabled for transmission\")\n\tpublic boolean isPacketSlicing()\n\t{\n\t\treturn packetSlicing;\n\t}\n\n\tpublic void setPacketSlicing(boolean packetSlicing)\n\t{\n\t\tthis.packetSlicing = packetSlicing;\n\t}\n\n\t@ManagedAttribute(description = \"If the packet grouping is enabled for transmission\")\n\tpublic boolean isPacketGrouping()\n\t{\n\t\treturn packetGrouping;\n\t}\n\n\tpublic void setPacketGrouping(boolean packetGrouping)\n\t{\n\t\tthis.packetGrouping = packetGrouping;\n\t}\n\n\t@ManagedAttribute(description = \"The encryption used for tunnels\")\n\tpublic String getTunnelEncryption()\n\t{\n\t\treturn tunnelEncryption;\n\t}\n\n\tpublic void setTunnelEncryption(String tunnelEncryption)\n\t{\n\t\tthis.tunnelEncryption = tunnelEncryption;\n\t}\n\n\t@ManagedAttribute(description = \"The file transfer strategy\")\n\tpublic String getFileTransferStrategy()\n\t{\n\t\treturn fileTransferStrategy;\n\t}\n\n\tpublic void setFileTransferStrategy(String fileTransferStrategy)\n\t{\n\t\tthis.fileTransferStrategy = fileTransferStrategy;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/BoardMessageService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.service.board.BoardRsService;\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.apache.commons.collections4.SetUtils;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\n@Service\npublic class BoardMessageService\n{\n\tprivate final BoardRsService boardRsService;\n\tprivate final IdentityService identityService;\n\n\tpublic BoardMessageService(@Lazy BoardRsService boardRsService, IdentityService identityService)\n\t{\n\t\tthis.boardRsService = boardRsService;\n\t\tthis.identityService = identityService;\n\t}\n\n\tpublic Map<GxsId, IdentityGroupItem> getAuthorsMapFromMessages(Page<BoardMessageItem> boardMessages)\n\t{\n\t\tvar authors = boardMessages.stream()\n\t\t\t\t.map(BoardMessageItem::getAuthorGxsId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn identityService.findAll(authors).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, BoardMessageItem> getMessagesMapFromSummaries(long groupId, Page<BoardMessageItem> boardMessages)\n\t{\n\t\tvar msgIds = boardMessages.stream()\n\t\t\t\t.map(BoardMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = boardMessages.stream()\n\t\t\t\t.map(BoardMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn boardRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(BoardMessageItem::getMsgId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, BoardMessageItem> getMessagesMapFromMessages(List<BoardMessageItem> boardMessages)\n\t{\n\t\tvar msgIds = boardMessages.stream()\n\t\t\t\t.map(BoardMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = boardMessages.stream()\n\t\t\t\t.map(BoardMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn boardRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(BoardMessageItem::getMsgId, Function.identity()));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/CapabilityService.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.application.autostart.AutoStart;\nimport io.xeres.common.rest.config.Capabilities;\nimport org.springframework.stereotype.Service;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\n/**\n * Service that informs about the capabilities supported by the system.\n * Usually dependent on the platform or installation.\n */\n@Service\npublic class CapabilityService\n{\n\tprivate final AutoStart autoStart;\n\n\tpublic CapabilityService(AutoStart autoStart)\n\t{\n\t\tthis.autoStart = autoStart;\n\t}\n\n\t/**\n\t * Informs about the capabilities supported by the system.\n\t *\n\t * @return a set of the supported capabilities.\n\t */\n\tpublic Set<String> getCapabilities()\n\t{\n\t\tSet<String> capabilities = new HashSet<>();\n\n\t\tif (autoStart.isSupported())\n\t\t{\n\t\t\tcapabilities.add(Capabilities.AUTOSTART);\n\t\t}\n\t\treturn capabilities;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/ChannelMessageService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.service.channel.ChannelRsService;\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.apache.commons.collections4.SetUtils;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\n@Service\npublic class ChannelMessageService\n{\n\tprivate final ChannelRsService channelRsService;\n\tprivate final IdentityService identityService;\n\n\tpublic ChannelMessageService(ChannelRsService channelRsService, IdentityService identityService)\n\t{\n\t\tthis.channelRsService = channelRsService;\n\t\tthis.identityService = identityService;\n\t}\n\n\tpublic Map<GxsId, IdentityGroupItem> getAuthorsMapFromMessages(Page<ChannelMessageItem> channelMessages)\n\t{\n\t\tvar authors = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getAuthorGxsId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn identityService.findAll(authors).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, ChannelMessageItem> getMessagesMapFromSummaries(long groupId, Page<ChannelMessageItem> channelMessages)\n\t{\n\t\tvar msgIds = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn channelRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(ChannelMessageItem::getMsgId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, ChannelMessageItem> getMessagesMapFromMessages(List<ChannelMessageItem> channelMessages)\n\t{\n\t\tvar msgIds = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn channelRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(ChannelMessageItem::getMsgId, Function.identity()));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/ContactService.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.contact.Contact;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n@Service\npublic class ContactService\n{\n\tprivate final ProfileService profileService;\n\tprivate final IdentityService identityService;\n\n\tpublic ContactService(@Lazy ProfileService profileService, IdentityService identityService)\n\t{\n\t\tthis.profileService = profileService;\n\t\tthis.identityService = identityService;\n\t}\n\n\t@Transactional(readOnly = true)\n\tpublic List<Contact> getContacts()\n\t{\n\t\t// Send identities and profiles.\n\t\tvar profiles = profileService.getAllProfiles().stream()\n\t\t\t\t.collect(Collectors.toMap(Profile::getId, profile -> profile));\n\t\tvar identities = identityService.getAll();\n\n\t\tList<Contact> contacts = new ArrayList<>(profiles.size() + identities.size());\n\t\tprofiles.forEach((key, value) -> contacts.add(new Contact(value.getName(), key, 0L, getAvailability(value), value.isAccepted())));\n\t\tidentities.forEach(identity -> contacts.add(new Contact(\n\t\t\t\tidentity.getName(),\n\t\t\t\tidentity.getProfile() != null ? identity.getProfile().getId() : 0L,\n\t\t\t\tidentity.getId(),\n\t\t\t\tgetAvailability(identity.getProfile()),\n\t\t\t\tisAccepted(identity.getProfile()))));\n\t\treturn contacts;\n\t}\n\n\tpublic List<Contact> getContactsForProfileId(long profileId)\n\t{\n\t\tvar contacts = identityService.findAllByProfileId(profileId);\n\t\treturn toContacts(contacts);\n\t}\n\n\tpublic List<Contact> toContacts(List<IdentityGroupItem> identities)\n\t{\n\t\tList<Contact> contacts = new ArrayList<>(identities.size());\n\t\tidentities.forEach(identity -> contacts.add(new Contact(\n\t\t\t\tidentity.getName(),\n\t\t\t\tidentity.getProfile() != null ? identity.getProfile().getId() : 0L,\n\t\t\t\tidentity.getId(),\n\t\t\t\tgetAvailability(identity.getProfile()),\n\t\t\t\tisAccepted(identity.getProfile()))));\n\t\treturn contacts;\n\t}\n\n\tpublic Contact toContact(Profile profile)\n\t{\n\t\treturn new Contact(profile.getName(), profile.getId(), 0L, getAvailability(profile), isAccepted(profile));\n\t}\n\n\tprivate Availability getAvailability(Profile profile)\n\t{\n\t\tif (profile != null)\n\t\t{\n\t\t\treturn profile.getBestAvailability();\n\t\t}\n\t\treturn Availability.OFFLINE;\n\t}\n\n\tprivate boolean isAccepted(Profile profile)\n\t{\n\t\treturn profile != null && profile.isAccepted();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/ForumMessageService.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.forum.ForumMessageItemSummary;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.service.forum.ForumRsService;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport org.apache.commons.collections4.SetUtils;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\n/**\n * Message helper service because they're hard to retrieve otherwise.\n */\n@Service\npublic class ForumMessageService\n{\n\tprivate final ForumRsService forumRsService;\n\tprivate final IdentityService identityService;\n\n\tpublic ForumMessageService(@Lazy ForumRsService forumRsService, IdentityService identityService)\n\t{\n\t\tthis.forumRsService = forumRsService;\n\t\tthis.identityService = identityService;\n\t}\n\n\tpublic Map<GxsId, IdentityGroupItem> getAuthorsMapFromSummaries(Page<ForumMessageItemSummary> forumMessages)\n\t{\n\t\tvar authors = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItemSummary::getAuthorGxsId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn identityService.findAll(authors).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity()));\n\t}\n\n\tpublic Map<GxsId, IdentityGroupItem> getAuthorsMapFromMessages(List<ForumMessageItem> forumMessages)\n\t{\n\t\tvar authors = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItem::getAuthorGxsId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn identityService.findAll(authors).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, ForumMessageItem> getMessagesMapFromSummaries(long groupId, Page<ForumMessageItemSummary> forumMessages)\n\t{\n\t\tvar msgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItemSummary::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItemSummary::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn forumRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(ForumMessageItem::getMsgId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, ForumMessageItem> getMessagesMapFromMessages(long groupId, List<ForumMessageItem> forumMessages)\n\t{\n\t\tvar msgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\t// XXX: update? why isn't this used?\n\n\t\treturn forumRsService.findAllMessages(groupId, SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(ForumMessageItem::getMsgId, Function.identity()));\n\t}\n\n\tpublic Map<MsgId, ForumMessageItem> getMessagesMapFromMessages(List<ForumMessageItem> forumMessages)\n\t{\n\t\tvar msgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar originalMsgIds = forumMessages.stream()\n\t\t\t\t.map(ForumMessageItem::getOriginalMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar map = forumRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(ForumMessageItem::getMsgId, Function.identity()));\n\n\t\tif (!originalMsgIds.isEmpty())\n\t\t{\n\t\t\tforumRsService.findAllOldMessages(originalMsgIds).forEach(forumMessageItem -> map.put(forumMessageItem.getMsgId(), forumMessageItem));\n\t\t}\n\n\t\treturn map;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/GeoIpService.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport com.maxmind.geoip2.DatabaseReader;\nimport com.maxmind.geoip2.exception.GeoIp2Exception;\nimport io.xeres.common.geoip.Country;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.net.InetAddress;\n\n@Service\npublic class GeoIpService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GeoIpService.class);\n\tprivate final DatabaseReader databaseReader;\n\n\tpublic GeoIpService(DatabaseReader databaseReader)\n\t{\n\t\tthis.databaseReader = databaseReader;\n\t}\n\n\tpublic Country getCountry(String ipAddress)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar country = databaseReader.country(InetAddress.getByName(ipAddress));\n\t\t\treturn Country.valueOf(country.country().isoCode());\n\t\t}\n\t\tcatch (IOException | GeoIp2Exception | IllegalArgumentException e)\n\t\t{\n\t\t\tlog.error(\"No country found for IP {}: {}\", ipAddress, e.getMessage());\n\t\t\treturn null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/IdentityService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.database.repository.GxsIdentityRepository;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.dto.identity.IdentityConstants;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.identity.Type;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Propagation;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\n@Service\npublic class IdentityService\n{\n\tprivate final GxsIdentityRepository gxsIdentityRepository;\n\n\tpublic IdentityService(GxsIdentityRepository gxsIdentityRepository)\n\t{\n\t\tthis.gxsIdentityRepository = gxsIdentityRepository;\n\t}\n\n\tpublic Optional<IdentityGroupItem> findById(long id)\n\t{\n\t\treturn gxsIdentityRepository.findById(id);\n\t}\n\n\tpublic boolean hasOwnIdentity()\n\t{\n\t\treturn gxsIdentityRepository.findById(IdentityConstants.OWN_IDENTITY_ID).isPresent();\n\t}\n\n\tpublic IdentityGroupItem getOwnIdentity()\n\t{\n\t\treturn gxsIdentityRepository.findById(IdentityConstants.OWN_IDENTITY_ID).orElseThrow(() -> new IllegalStateException(\"Missing own gxsId\"));\n\t}\n\n\tpublic List<IdentityGroupItem> findAllByName(String name)\n\t{\n\t\treturn gxsIdentityRepository.findAllByName(name);\n\t}\n\n\tpublic Optional<IdentityGroupItem> findByGxsId(GxsId gxsId)\n\t{\n\t\treturn gxsIdentityRepository.findByGxsId(gxsId);\n\t}\n\n\tpublic List<IdentityGroupItem> findAllByType(Type type)\n\t{\n\t\treturn gxsIdentityRepository.findAllByType(type);\n\t}\n\n\tpublic List<IdentityGroupItem> getAll()\n\t{\n\t\treturn gxsIdentityRepository.findAll();\n\t}\n\n\tpublic List<IdentityGroupItem> findAll(Set<GxsId> gxsIds)\n\t{\n\t\treturn gxsIdentityRepository.findAllByGxsIdIn(gxsIds);\n\t}\n\n\tpublic List<IdentityGroupItem> findAllSubscribed()\n\t{\n\t\treturn gxsIdentityRepository.findAllBySubscribedIsTrue();\n\t}\n\n\tpublic List<IdentityGroupItem> findAllByProfileId(long id)\n\t{\n\t\treturn gxsIdentityRepository.findAllByProfileId(id);\n\t}\n\n\t@Transactional\n\tpublic IdentityGroupItem save(IdentityGroupItem identityGroupItem)\n\t{\n\t\treturn gxsIdentityRepository.save(identityGroupItem);\n\t}\n\n\tpublic List<IdentityGroupItem> findIdentitiesToValidate(int limit)\n\t{\n\t\treturn gxsIdentityRepository.findAllByNextValidationNotNullAndNextValidationBeforeOrderByNextValidationDesc(Instant.now(), limit <= 0 ? Limit.unlimited() : Limit.of(limit));\n\t}\n\n\tpublic void delete(IdentityGroupItem identityGroupItem)\n\t{\n\t\tgxsIdentityRepository.delete(identityGroupItem);\n\t}\n\n\t@Transactional(propagation = Propagation.NEVER)\n\tpublic byte[] signData(IdentityGroupItem identityGroupItem, byte[] data)\n\t{\n\t\treturn RSA.sign(identityGroupItem.getAdminPrivateKey(), data);\n\t}\n\n\t@Transactional\n\tpublic void removeAllLinksToProfile(long profileId)\n\t{\n\t\tvar allByProfileId = gxsIdentityRepository.findAllByProfileId(profileId);\n\t\tallByProfileId.forEach(identityGroupItem -> identityGroupItem.setProfile(null));\n\t\t// XXX: we should possibly refresh the list with contactNotificationService...\n\t\tgxsIdentityRepository.saveAll(allByProfileId);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/InfoService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.netty.util.ResourceLeakDetector;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.common.util.ByteUnitUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.info.BuildProperties;\nimport org.springframework.core.env.Environment;\nimport org.springframework.stereotype.Service;\n\nimport java.lang.management.ManagementFactory;\nimport java.nio.charset.Charset;\nimport java.time.Duration;\nimport java.util.Locale;\nimport java.util.stream.Collectors;\n\n@Service\npublic class InfoService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(InfoService.class);\n\n\tprivate final BuildProperties buildProperties;\n\tprivate final Environment environment;\n\tprivate final NetworkProperties networkProperties;\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\n\tpublic InfoService(BuildProperties buildProperties, Environment environment, NetworkProperties networkProperties, RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tthis.buildProperties = buildProperties;\n\t\tthis.environment = environment;\n\t\tthis.networkProperties = networkProperties;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t}\n\n\tpublic void showStartupInfo()\n\t{\n\t\tlog.info(\"Startup sequence ({}, {}, {})\",\n\t\t\t\tbuildProperties.getName(),\n\t\t\t\tbuildProperties.getVersion(),\n\t\t\t\tenvironment.getActiveProfiles().length > 0 ? environment.getActiveProfiles()[0] : \"prod\");\n\t}\n\n\tpublic void showCapabilities()\n\t{\n\t\tlog.info(\"OS: {} ({})\", System.getProperty(\"os.name\"), System.getProperty(\"os.arch\"));\n\t\tlog.info(\"JRE: {} {} ({})\", System.getProperty(\"java.vendor\"), System.getProperty(\"java.version\"), System.getProperty(\"java.home\"));\n\t\tlog.info(\"Charset: {}\", Charset.defaultCharset());\n\t\tlog.info(\"Language: {}\", Locale.getDefault().getLanguage());\n\t\tlog.info(\"TCP/IP stack state: {}\", StringUtils.defaultString(System.getProperty(\"java.net.preferIPv4Stack\")).equals(\"true\") ? \"sane\" : \"broken\");\n\t\tlog.debug(\"Working directory: {}\", log.isDebugEnabled() ? System.getProperty(\"user.dir\") : \"\");\n\t\tlog.info(\"Number of processor threads: {}\", Runtime.getRuntime().availableProcessors());\n\t\tlog.info(\"Memory allocated for the JVM: {}\", ByteUnitUtils.fromBytes(Runtime.getRuntime().totalMemory()));\n\t\tlog.info(\"Maximum allocatable memory: {}\", ByteUnitUtils.fromBytes(Runtime.getRuntime().maxMemory()));\n\t}\n\n\tpublic void showFeatures()\n\t{\n\t\tif (log.isDebugEnabled())\n\t\t{\n\t\t\tlog.debug(\"Network features: {}\", networkProperties.getFeatures());\n\t\t\tlog.debug(\"Services: {}\", rsServiceRegistry.getServices().stream().map(rsService -> rsService.getServiceType().getName()).collect(Collectors.joining(\", \")));\n\t\t}\n\t}\n\n\tpublic void showDebug()\n\t{\n\t\tif (!log.isDebugEnabled())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (ResourceLeakDetector.isEnabled())\n\t\t{\n\t\t\tlog.debug(\"Netty leak detector level: {}\", ResourceLeakDetector.getLevel());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Netty leak detector disabled\");\n\t\t}\n\t}\n\n\t/**\n\t * Gets the uptime since the application startup.\n\t *\n\t * @return the uptime duration\n\t */\n\tpublic Duration getUptime()\n\t{\n\t\tvar startTime = ManagementFactory.getRuntimeMXBean().getStartTime();\n\t\tvar currentTime = System.currentTimeMillis();\n\t\tvar uptimeMillis = currentTime - startTime;\n\n\t\treturn Duration.ofMillis(uptimeMillis);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/LocationService.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.crypto.rsid.RSSerialVersion;\nimport io.xeres.app.crypto.x509.X509;\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.repository.LocationRepository;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.net.util.NetworkMode;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.protocol.NetMode;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.data.domain.PageRequest;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.domain.Slice;\nimport org.springframework.data.domain.Sort;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.io.IOException;\nimport java.net.InetAddress;\nimport java.net.InetSocketAddress;\nimport java.net.SocketAddress;\nimport java.net.UnknownHostException;\nimport java.security.KeyPair;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.cert.CertificateException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.Instant;\nimport java.util.*;\n\nimport static io.xeres.app.net.util.NetworkMode.hasDht;\nimport static io.xeres.app.net.util.NetworkMode.isDiscoverable;\nimport static io.xeres.app.service.ResourceCreationState.*;\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\nimport static java.util.function.Predicate.not;\n\n@Service\npublic class LocationService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(LocationService.class);\n\n\tprivate static final int KEY_SIZE = 3072;\n\n\tprivate final SettingsService settingsService;\n\tprivate final ProfileService profileService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final LocationRepository locationRepository;\n\n\tprivate Slice<Location> locations;\n\tprivate int pageIndex;\n\tprivate int connectionIndex = -1;\n\n\tpublic LocationService(SettingsService settingsService, ProfileService profileService, PeerConnectionManager peerConnectionManager, LocationRepository locationRepository)\n\t{\n\t\tthis.settingsService = settingsService;\n\t\tthis.profileService = profileService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.locationRepository = locationRepository;\n\t}\n\n\tKeyPair generateLocationKeys()\n\t{\n\t\tif (settingsService.getLocationPrivateKeyData() != null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tlog.info(\"Generating keys, algorithm: RSA, bits: {} ...\", KEY_SIZE);\n\n\t\tvar keyPair = RSA.generateKeys(KEY_SIZE);\n\n\t\tlog.info(\"Successfully generated key pair\");\n\n\t\treturn keyPair;\n\t}\n\n\tbyte[] generateLocationCertificate(byte[] locationPublicKeyData) throws CertificateException, InvalidKeySpecException, NoSuchAlgorithmException, IOException\n\t{\n\t\tlog.info(\"Generating certificate...\");\n\n\t\tvar x509Certificate = X509.generateCertificate(\n\t\t\t\tPGP.getPGPSecretKey(settingsService.getSecretProfileKey()),\n\t\t\t\tRSA.getPublicKey(locationPublicKeyData),\n\t\t\t\t\"CN=\" + Long.toHexString(profileService.getOwnProfile().getPgpIdentifier()).toUpperCase(Locale.ROOT), // older RS use a random string I think, like 12:34:55:44:4e:44:99:23\n\t\t\t\t\"CN=-\",\n\t\t\t\tnew Date(0),\n\t\t\t\tnew Date(0),\n\t\t\t\tRSSerialVersion.V07_0001.serialNumber()\n\t\t);\n\n\t\tlog.info(\"Successfully generated certificate\");\n\n\t\treturn x509Certificate.getEncoded();\n\t}\n\n\t@Transactional\n\tpublic ResourceCreationState generateOwnLocation(String name)\n\t{\n\t\tif (!settingsService.isOwnProfilePresent())\n\t\t{\n\t\t\tlog.error(\"Cannot create a location without a profile; Create a profile first\");\n\t\t\treturn FAILED;\n\t\t}\n\t\tvar ownProfile = profileService.getOwnProfile();\n\n\t\tif (!ownProfile.getLocations().isEmpty())\n\t\t{\n\t\t\treturn ALREADY_EXISTS;\n\t\t}\n\n\t\tvar keyPair = generateLocationKeys();\n\t\tbyte[] x509Certificate;\n\n\t\ttry\n\t\t{\n\t\t\tx509Certificate = generateLocationCertificate(keyPair.getPublic().getEncoded());\n\t\t\tcreateOwnLocation(name, keyPair, x509Certificate);\n\t\t}\n\t\tcatch (InvalidKeySpecException | NoSuchAlgorithmException | IOException | CertificateException e)\n\t\t{\n\t\t\tlog.error(\"Failed to generate certificate: {}\", e.getMessage());\n\t\t\treturn FAILED;\n\t\t}\n\t\treturn CREATED;\n\t}\n\n\t@Transactional\n\tpublic void createOwnLocation(String name, KeyPair keyPair, byte[] x509Certificate) throws CertificateException\n\t{\n\t\tsettingsService.saveLocationKeys(keyPair);\n\t\tsettingsService.saveLocationCertificate(x509Certificate);\n\n\t\tvar location = Location.createLocation(name);\n\t\tlocation.setLocationIdentifier(X509.getLocationIdentifier(X509.getCertificate(settingsService.getLocationCertificate())));\n\t\tprofileService.getOwnProfile().addLocation(location);\n\t\tlocationRepository.save(location);\n\t}\n\n\t/**\n\t * Find the location.\n\t *\n\t * @param locationIdentifier the SSL identifier\n\t * @return the location\n\t */\n\tpublic Optional<Location> findLocationByLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\treturn locationRepository.findByLocationIdentifier(locationIdentifier);\n\t}\n\n\tpublic Optional<Location> findOwnLocation()\n\t{\n\t\treturn locationRepository.findById(OWN_LOCATION_ID);\n\t}\n\n\tpublic Optional<Location> findLocationById(long id)\n\t{\n\t\treturn locationRepository.findById(id);\n\t}\n\n\tpublic boolean isServiceSupported(Location location, int serviceId)\n\t{\n\t\treturn peerConnectionManager.isServiceSupported(location, serviceId);\n\t}\n\n\tpublic boolean hasOwnLocation()\n\t{\n\t\treturn findOwnLocation().isPresent();\n\t}\n\n\tpublic void markAllConnectionsAsDisconnected()\n\t{\n\t\tlocationRepository.putAllConnectedToFalse();\n\t}\n\n\tpublic long countLocations()\n\t{\n\t\treturn locationRepository.count();\n\t}\n\n\t@Transactional\n\tpublic void markAsAvailable()\n\t{\n\t\tvar ownLocation = findOwnLocation().orElseThrow();\n\t\townLocation.setAvailability(Availability.AVAILABLE);\n\t\tlocationRepository.save(ownLocation);\n\t}\n\n\t@Transactional\n\tpublic void setConnected(Location location, SocketAddress socketAddress)\n\t{\n\t\tupdateConnection(location, socketAddress); // XXX: is this the right place? maybe it should be done in discovery service\n\n\t\tlocation.setConnected(true);\n\t\t// @Transactional should save it automatically, but I'm not sure when exactly. To detect simultaneous connections,\n\t\t// we need to make sure that this method has an updated location.\n\t\tlocationRepository.save(location);\n\t}\n\n\tprivate static void updateConnection(Location location, SocketAddress socketAddress)\n\t{\n\t\tvar inetSocketAddress = (InetSocketAddress) socketAddress;\n\n\t\tlocation.getConnections().stream()\n\t\t\t\t.filter(conn -> conn.getAddress().split(\":\")[0].equals(inetSocketAddress.getHostString()))\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(connection -> connection.setLastConnected(Instant.now()));\n\t}\n\n\t@Transactional\n\tpublic void setDisconnected(Location location)\n\t{\n\t\tlocation.setConnected(false);\n\t\tlocationRepository.save(location); // This is needed because PeerHandler calls it from a non managed context\n\t}\n\n\t@Transactional\n\tpublic void setAvailability(Location location, Availability availability)\n\t{\n\t\tlocation.setAvailability(availability);\n\t\tlocationRepository.save(location);\n\t}\n\n\t@Transactional\n\tpublic Location update(Location location, String locationName, NetMode netMode, String version, NetworkMode networkMode, List<PeerAddress> peerAddresses)\n\t{\n\t\tlocation.setName(locationName);\n\t\tlocation.setNetMode(netMode);\n\t\tlocation.setVersion(version);\n\t\tlocation.setDiscoverable(isDiscoverable(networkMode));\n\t\tlocation.setDht(hasDht(networkMode));\n\t\tpeerAddresses.forEach(peerAddress -> updateConnection(location, peerAddress));\n\t\treturn locationRepository.save(location);\n\t}\n\n\t@Transactional\n\tpublic List<Connection> getConnectionsToConnectTo(int simultaneousLocations)\n\t{\n\t\tvar ownConnection = findOwnLocation().orElseThrow()\n\t\t\t\t.getConnections()\n\t\t\t\t.stream()\n\t\t\t\t.filter(Connection::isExternal)\n\t\t\t\t.findFirst().orElse(null);\n\n\t\tvar ownIp = ownConnection != null ? ownConnection.getIp() : null;\n\n\t\tlocations = locationRepository.findAllByConnectedFalse(PageRequest.of(getPageIndex(), simultaneousLocations, Sort.by(\"lastConnected\").descending()));\n\n\t\treturn locations.stream()\n\t\t\t\t.filter(not(Location::isOwn))\n\t\t\t\t.flatMap(location -> location.getBestConnection(getConnectionIndex(), ownIp))\n\t\t\t\t.limit(simultaneousLocations)\n\t\t\t\t.toList();\n\t}\n\n\tpublic Slice<Location> getUnconnectedLocationsWithDht(Pageable pageable)\n\t{\n\t\treturn locationRepository.findAllByConnectedFalseAndDhtTrue(pageable);\n\t}\n\n\tpublic List<Location> getConnectedLocations()\n\t{\n\t\treturn locationRepository.findAllByConnectedTrue();\n\t}\n\n\tpublic List<Location> getAllLocations()\n\t{\n\t\treturn locationRepository.findAll();\n\t}\n\n\t@Transactional\n\tpublic void updateConnection(Location location, PeerAddress peerAddress)\n\t{\n\t\tif (peerAddress.isInvalid())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (location.isOwn())\n\t\t{\n\t\t\tupdateOwnConnection(location, peerAddress);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tupdateOtherConnection(location, peerAddress);\n\t\t}\n\t}\n\n\tprivate static void updateOwnConnection(Location location, PeerAddress peerAddress)\n\t{\n\t\tvar updated = false;\n\n\t\tfor (var connection : location.getConnections())\n\t\t{\n\t\t\tupdated = updateAddressIfSameType(peerAddress, connection);\n\t\t\tif (updated)\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif (!updated)\n\t\t{\n\t\t\tlocation.addConnection(Connection.from(peerAddress));\n\t\t}\n\t}\n\n\tprivate static void updateOtherConnection(Location location, PeerAddress peerAddress)\n\t{\n\t\tlocation.addConnection(Connection.from(peerAddress));\n\t}\n\n\tpublic String getHostname() throws UnknownHostException\n\t{\n\t\treturn InetAddress.getLocalHost().getHostName();\n\t}\n\n\tpublic String getUsername()\n\t{\n\t\tvar username = System.getProperty(\"user.name\");\n\t\tif (StringUtils.isEmpty(username))\n\t\t{\n\t\t\tthrow new NoSuchElementException(\"No logged in username\");\n\t\t}\n\t\treturn username;\n\t}\n\n\tprivate static boolean updateAddressIfSameType(PeerAddress from, Connection to)\n\t{\n\t\tif ((from.isExternal() && to.isExternal())\n\t\t\t\t|| (!from.isExternal() && !to.isExternal()))\n\t\t{\n\t\t\tto.setAddress(from.getAddress().orElseThrow());\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate int getPageIndex()\n\t{\n\t\tif (locations == null || locations.isLast())\n\t\t{\n\t\t\tpageIndex = 0;\n\t\t\tconnectionIndex++;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tpageIndex++;\n\t\t}\n\t\treturn pageIndex;\n\t}\n\n\tprivate int getConnectionIndex()\n\t{\n\t\treturn connectionIndex;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/MessageService.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.message.MessageType;\nimport org.springframework.messaging.simp.SimpMessageSendingOperations;\nimport org.springframework.stereotype.Service;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Objects;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\n\n@Service\npublic class MessageService\n{\n\tprivate final SimpMessageSendingOperations messagingTemplate;\n\n\tpublic MessageService(SimpMessageSendingOperations messagingTemplate)\n\t{\n\t\tthis.messagingTemplate = messagingTemplate;\n\t}\n\n\tpublic void sendToConsumers(String path, MessageType type, Object payload)\n\t{\n\t\tvar headers = buildMessageHeaders(type);\n\t\tsendToConsumers(path, headers, payload);\n\t}\n\n\tpublic void sendToConsumers(String path, MessageType type, long destination, Object payload)\n\t{\n\t\tvar headers = buildMessageHeaders(type, String.valueOf(destination));\n\t\tsendToConsumers(path, headers, payload);\n\t}\n\n\tpublic void sendToConsumers(String path, MessageType type, Identifier destination, Object payload)\n\t{\n\t\tvar headers = buildMessageHeaders(type, destination.toString());\n\t\tsendToConsumers(path, headers, payload);\n\t}\n\n\tprivate void sendToConsumers(String path, Map<String, Object> headers, Object payload)\n\t{\n\t\tObjects.requireNonNull(payload, \"Payload *must* be an object that can be serialized to JSON\");\n\t\tmessagingTemplate.convertAndSend(path, payload, headers);\n\t}\n\n\tprivate static Map<String, Object> buildMessageHeaders(MessageType messageType, String id)\n\t{\n\t\tMap<String, Object> headers = new HashMap<>();\n\t\theaders.put(MESSAGE_TYPE, messageType.name());\n\t\tif (id != null)\n\t\t{\n\t\t\theaders.put(DESTINATION_ID, id);\n\t\t}\n\t\treturn headers;\n\t}\n\n\tprivate static Map<String, Object> buildMessageHeaders(MessageType messageType)\n\t{\n\t\treturn buildMessageHeaders(messageType, null);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/NetworkService.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.application.events.IpChangedEvent;\nimport io.xeres.app.application.events.LocationReadyEvent;\nimport io.xeres.app.application.events.NetworkReadyEvent;\nimport io.xeres.app.application.events.UpnpEvent;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.settings.Settings;\nimport io.xeres.app.net.bdisc.BroadcastDiscoveryService;\nimport io.xeres.app.net.dht.DhtService;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.net.upnp.UPNPService;\nimport io.xeres.common.events.ConnectWebSocketsEvent;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.protocol.ip.IP;\nimport org.apache.commons.lang3.Strings;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Service;\n\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\nimport static io.xeres.common.properties.StartupProperties.Property.CONTROL_PORT;\nimport static io.xeres.common.properties.StartupProperties.Property.SERVER_PORT;\n\n@Service\npublic class NetworkService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(NetworkService.class);\n\n\tprivate String localIpAddress;\n\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final IdentityService identityService;\n\tprivate final PeerService peerService;\n\tprivate final UPNPService upnpService;\n\tprivate final BroadcastDiscoveryService broadcastDiscoveryService;\n\tprivate final DhtService dhtService;\n\tprivate final SettingsService settingsService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ApplicationEventPublisher publisher;\n\n\tprivate final AtomicBoolean running = new AtomicBoolean();\n\tprivate boolean startWhenPossible;\n\n\tpublic NetworkService(ProfileService profileService, LocationService locationService, IdentityService identityService, PeerService peerService, UPNPService upnpService, BroadcastDiscoveryService broadcastDiscoveryService, DhtService dhtService, SettingsService settingsService, DatabaseSessionManager databaseSessionManager, ApplicationEventPublisher publisher)\n\t{\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.identityService = identityService;\n\t\tthis.peerService = peerService;\n\t\tthis.upnpService = upnpService;\n\t\tthis.broadcastDiscoveryService = broadcastDiscoveryService;\n\t\tthis.dhtService = dhtService;\n\t\tthis.settingsService = settingsService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.publisher = publisher;\n\t}\n\n\tpublic boolean checkReadiness()\n\t{\n\t\tif (profileService.hasOwnProfile() && locationService.hasOwnLocation() && identityService.hasOwnIdentity())\n\t\t{\n\t\t\tconfigure();\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate void configure()\n\t{\n\t\tconfigureLocalPort();\n\t\tpublisher.publishEvent(new LocationReadyEvent());\n\t}\n\n\tprivate int configureLocalPort()\n\t{\n\t\tif (settingsService.getLocalPort() == 0)\n\t\t{\n\t\t\tvar localPort = Optional.ofNullable(StartupProperties.getInteger(SERVER_PORT)).orElseGet(IP::getFreeLocalPort);\n\t\t\tif (localPort != 0)\n\t\t\t{\n\t\t\t\tlog.info(\"Using local port {}\", localPort);\n\t\t\t\tsettingsService.setLocalPort(localPort);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"No network available to configure the local port\");\n\t\t\t}\n\t\t}\n\t\treturn settingsService.getLocalPort();\n\t}\n\n\tpublic String getLocalIpAddress()\n\t{\n\t\treturn localIpAddress;\n\t}\n\n\tpublic int getPort()\n\t{\n\t\treturn settingsService.getLocalPort();\n\t}\n\n\tpublic void start()\n\t{\n\t\tif (running.compareAndSet(false, true))\n\t\t{\n\t\t\tlocalIpAddress = IP.getLocalIpAddress();\n\t\t\tvar localPort = configureLocalPort();\n\n\t\t\tlocationService.markAllConnectionsAsDisconnected();\n\t\t\tlocationService.markAsAvailable();\n\n\t\t\tlog.info(\"Starting network services...\");\n\t\t\tvar ownAddress = PeerAddress.from(localIpAddress, localPort);\n\t\t\tif (ownAddress.isValid())\n\t\t\t{\n\t\t\t\ttry (var _ = new DatabaseSession(databaseSessionManager))\n\t\t\t\t{\n\t\t\t\t\tlocationService.updateConnection(locationService.findOwnLocation().orElseThrow(), ownAddress);\n\t\t\t\t}\n\t\t\t\tstartHelperServices(ownAddress.isLAN(), false);\n\n\t\t\t\tpeerService.start(localPort);\n\n\t\t\t\tstartWhenPossible = false;\n\n\t\t\t\tpublisher.publishEvent(new NetworkReadyEvent());\n\t\t\t\tpublisher.publishEvent(new ConnectWebSocketsEvent());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.error(\"Local address is invalid: {}, can't start network services\", localIpAddress);\n\t\t\t\trunning.set(false);\n\t\t\t\tstartWhenPossible = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void startHelperServices(boolean isLan, boolean restart)\n\t{\n\t\tif (isLan)\n\t\t{\n\t\t\tif (settingsService.isUpnpEnabled())\n\t\t\t{\n\t\t\t\tif (restart)\n\t\t\t\t{\n\t\t\t\t\tdhtService.stop();\n\t\t\t\t\tupnpService.stop();\n\t\t\t\t}\n\t\t\t\tupnpService.start(localIpAddress, settingsService.getLocalPort(), settingsService.isUpnpRemoteEnabled() ? Objects.requireNonNull(StartupProperties.getInteger(CONTROL_PORT)) : 0);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tstartDhtIfNeeded(restart);\n\t\t\t}\n\t\t\tstartBroadcastDiscoveryIfNeeded(restart);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tupnpService.stop();\n\t\t\tbroadcastDiscoveryService.stop();\n\t\t\tstartDhtIfNeeded(restart);\n\t\t}\n\t}\n\n\tpublic void stop()\n\t{\n\t\tstartWhenPossible = false;\n\n\t\tif (running.compareAndSet(true, false))\n\t\t{\n\t\t\tdhtService.stop();\n\t\t\tupnpService.stop();\n\t\t\tbroadcastDiscoveryService.stop();\n\t\t\tpeerService.stop();\n\n\t\t\tupnpService.waitForTermination();\n\t\t}\n\t}\n\n\tpublic void compareSettingsAndApplyActions(Settings oldSettings, Settings newSettings)\n\t{\n\t\tapplyBroadcastDiscovery(oldSettings, newSettings);\n\t\tapplyDht(oldSettings, newSettings);\n\t\tapplyUpnp(oldSettings, newSettings);\n\t\tapplyTor(oldSettings, newSettings);\n\t\tapplyI2p(oldSettings, newSettings);\n\t}\n\n\t@Scheduled(initialDelay = 2, fixedDelay = 1, timeUnit = TimeUnit.MINUTES)\n\tvoid checkIp()\n\t{\n\t\tif (!locationService.hasOwnLocation())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar newLocalIpAddress = IP.getLocalIpAddress();\n\t\tif (newLocalIpAddress == null)\n\t\t{\n\t\t\tlog.error(\"No TCP/IP stack available...\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (!newLocalIpAddress.equals(localIpAddress))\n\t\t{\n\t\t\tlog.warn(\"Local IP address changed: {} -> {}\", localIpAddress, newLocalIpAddress);\n\t\t\tpublisher.publishEvent(new IpChangedEvent(newLocalIpAddress));\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onIpChangedEvent(IpChangedEvent event)\n\t{\n\t\tlog.warn(\"IP change event received, possibly restarting some services...\");\n\n\t\tif (!running.get() && startWhenPossible)\n\t\t{\n\t\t\tstart();\n\t\t\treturn;\n\t\t}\n\n\t\tif (!IP.isRoutableIp(localIpAddress))\n\t\t{\n\t\t\tstop();\n\t\t\tstartWhenPossible = true;\n\t\t\treturn;\n\t\t}\n\n\t\tlocalIpAddress = event.localIpAddress();\n\n\t\tstartHelperServices(IP.isLanIp(localIpAddress), true);\n\t}\n\n\t@EventListener\n\tpublic void onUpnpEvent(UpnpEvent event)\n\t{\n\t\tif (event.portsForwarded())\n\t\t{\n\t\t\tlog.info(\"Ports forwarded on the router\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.info(\"Ports not forwarded on the router\");\n\t\t}\n\t\tif (!event.externalIpFound())\n\t\t{\n\t\t\tlog.warn(\"External IP address not found\");\n\t\t}\n\n\t\t// We start the DHT here because it's better when the incoming port is working first.\n\t\t// But it can still work without it.\n\t\tif (settingsService.isDhtEnabled())\n\t\t{\n\t\t\tdhtService.start(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), event.localPort());\n\t\t}\n\t}\n\n\tprivate void startDhtIfNeeded(boolean restart)\n\t{\n\t\tif (settingsService.isDhtEnabled())\n\t\t{\n\t\t\tif (restart)\n\t\t\t{\n\t\t\t\tdhtService.stop();\n\t\t\t}\n\t\t\tdhtService.start(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), settingsService.getLocalPort());\n\t\t}\n\t}\n\n\tprivate void startBroadcastDiscoveryIfNeeded(boolean restart)\n\t{\n\t\tif (settingsService.isBroadcastDiscoveryEnabled())\n\t\t{\n\t\t\tif (restart)\n\t\t\t{\n\t\t\t\tbroadcastDiscoveryService.stop();\n\t\t\t}\n\t\t\tbroadcastDiscoveryService.start(localIpAddress, settingsService.getLocalPort());\n\t\t}\n\t}\n\n\tprivate void applyBroadcastDiscovery(Settings oldSettings, Settings newSettings)\n\t{\n\t\tif (newSettings.isBroadcastDiscoveryEnabled() != oldSettings.isBroadcastDiscoveryEnabled())\n\t\t{\n\t\t\tif (newSettings.isBroadcastDiscoveryEnabled())\n\t\t\t{\n\t\t\t\tbroadcastDiscoveryService.start(localIpAddress, newSettings.getLocalPort());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tbroadcastDiscoveryService.stop();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void applyDht(Settings oldSettings, Settings newSettings)\n\t{\n\t\tif (newSettings.isDhtEnabled() != oldSettings.isDhtEnabled())\n\t\t{\n\t\t\tif (newSettings.isDhtEnabled())\n\t\t\t{\n\t\t\t\tdhtService.start(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(), newSettings.getLocalPort());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tdhtService.stop();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void applyUpnp(Settings oldSettings, Settings newSettings)\n\t{\n\t\tif (newSettings.isUpnpEnabled() != oldSettings.isUpnpEnabled())\n\t\t{\n\t\t\tif (newSettings.isUpnpEnabled())\n\t\t\t{\n\t\t\t\tupnpService.start(localIpAddress, newSettings.getLocalPort(), getRemotePortForUpnp(newSettings));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tupnpService.stop();\n\t\t\t}\n\t\t}\n\t\telse if (newSettings.isUpnpRemoteEnabled() != oldSettings.isUpnpRemoteEnabled())\n\t\t{\n\t\t\tupnpService.stop();\n\t\t\tupnpService.start(localIpAddress, newSettings.getLocalPort(), getRemotePortForUpnp(newSettings));\n\t\t}\n\t}\n\n\tprivate int getRemotePortForUpnp(Settings settings)\n\t{\n\t\treturn (settings.isRemoteEnabled() && settings.isUpnpRemoteEnabled()) ? Objects.requireNonNull(StartupProperties.getInteger(CONTROL_PORT)) : 0;\n\t}\n\n\tprivate void applyTor(Settings oldSettings, Settings newSettings)\n\t{\n\t\tif (!Strings.CS.equals(newSettings.getTorSocksHost(), oldSettings.getTorSocksHost()) || newSettings.getTorSocksPort() != oldSettings.getTorSocksPort())\n\t\t{\n\t\t\tpeerService.restartTor();\n\t\t}\n\t}\n\n\tprivate void applyI2p(Settings oldSettings, Settings newSettings)\n\t{\n\t\tif (!Strings.CS.equals(newSettings.getI2pSocksHost(), oldSettings.getI2pSocksHost()) || newSettings.getI2pSocksPort() != oldSettings.getI2pSocksPort())\n\t\t{\n\t\t\tpeerService.restartI2p();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/PeerService.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.net.peer.bootstrap.PeerI2pClient;\nimport io.xeres.app.net.peer.bootstrap.PeerTcpClient;\nimport io.xeres.app.net.peer.bootstrap.PeerTcpServer;\nimport io.xeres.app.net.peer.bootstrap.PeerTorClient;\nimport io.xeres.common.properties.StartupProperties;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport java.util.concurrent.atomic.AtomicBoolean;\n\nimport static io.xeres.common.properties.StartupProperties.Property.SERVER_ADDRESS;\nimport static io.xeres.common.properties.StartupProperties.Property.SERVER_ONLY;\n\n@Service\npublic class PeerService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(PeerService.class);\n\n\tprivate final PeerTcpClient peerTcpClient;\n\tprivate final PeerTorClient peerTorClient;\n\tprivate final PeerI2pClient peerI2pClient;\n\tprivate final PeerTcpServer peerTcpServer;\n\tprivate final SettingsService settingsService;\n\n\tprivate final AtomicBoolean running = new AtomicBoolean();\n\n\tpublic PeerService(PeerTcpClient peerTcpClient, PeerTorClient peerTorClient, PeerI2pClient peerI2pClient, PeerTcpServer peerTcpServer, SettingsService settingsService)\n\t{\n\t\tthis.peerTcpClient = peerTcpClient;\n\t\tthis.peerTorClient = peerTorClient;\n\t\tthis.peerI2pClient = peerI2pClient;\n\t\tthis.peerTcpServer = peerTcpServer;\n\t\tthis.settingsService = settingsService;\n\t}\n\n\tpublic void start(int localPort)\n\t{\n\t\tlog.info(\"Starting peer services on port {}\", localPort);\n\t\trunning.lazySet(true);\n\n\t\tpeerTcpServer.start(StartupProperties.getString(SERVER_ADDRESS), localPort);\n\t\tif (!StartupProperties.getBoolean(SERVER_ONLY, false))\n\t\t{\n\t\t\tpeerTcpClient.start();\n\t\t}\n\t\tstartTor();\n\t\tstartI2p();\n\t}\n\n\tpublic void stop()\n\t{\n\t\trunning.set(false);\n\t\tpeerTcpServer.stop();\n\t\tpeerTcpClient.stop();\n\t\tpeerTorClient.stop();\n\t\tpeerI2pClient.stop();\n\t}\n\n\tpublic void startTor()\n\t{\n\t\tif (settingsService.hasTorSocksConfigured())\n\t\t{\n\t\t\tpeerTorClient.start();\n\t\t}\n\t}\n\n\tpublic void stopTor()\n\t{\n\t\tpeerTorClient.stop();\n\t}\n\n\tpublic void restartTor()\n\t{\n\t\tstopTor();\n\t\tstartTor();\n\t}\n\n\tpublic void startI2p()\n\t{\n\t\tif (settingsService.hasI2pSocksConfigured())\n\t\t{\n\t\t\tpeerI2pClient.start();\n\t\t}\n\t}\n\n\tpublic void stopI2p()\n\t{\n\t\tpeerI2pClient.stop();\n\t}\n\n\tpublic void restartI2p()\n\t{\n\t\tstopI2p();\n\t\tstartI2p();\n\t}\n\n\tpublic boolean isRunning()\n\t{\n\t\treturn running.get();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/ProfileService.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.application.events.PeerDisconnectedEvent;\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsid.RSId;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.repository.ProfileRepository;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.common.AppName;\nimport io.xeres.common.dto.profile.ProfileConstants;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.rest.profile.ProfileKeyAttributes;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.io.IOException;\nimport java.security.InvalidKeyException;\nimport java.security.Security;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.service.ResourceCreationState.*;\nimport static io.xeres.common.Features.EXPERIMENTAL_EC;\n\n@Service\npublic class ProfileService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ProfileService.class);\n\n\tprivate static final int KEY_SIZE = EXPERIMENTAL_EC ? 255 : 3072;\n\tprivate static final int KEY_ID_LENGTH_MIN = ProfileConstants.NAME_LENGTH_MIN;\n\tprivate static final int KEY_ID_LENGTH_MAX = ProfileConstants.NAME_LENGTH_MAX;\n\tprivate static final String KEY_ID_SUFFIX = \"(Generated by \" + AppName.NAME + \")\";\n\n\tprivate final ProfileRepository profileRepository;\n\tprivate final SettingsService settingsService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\n\tprivate final Map<Profile, Set<LocationIdentifier>> profilesToDelete = HashMap.newHashMap(2);\n\tprivate final ContactNotificationService contactNotificationService;\n\n\tpublic ProfileService(ProfileRepository profileRepository, SettingsService settingsService, PeerConnectionManager peerConnectionManager, ContactNotificationService contactNotificationService)\n\t{\n\t\tthis.profileRepository = profileRepository;\n\t\tthis.settingsService = settingsService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\t\tthis.contactNotificationService = contactNotificationService;\n\t}\n\n\t@Transactional\n\tpublic ResourceCreationState generateProfileKeys(String name)\n\t{\n\t\tif (hasOwnProfile())\n\t\t{\n\t\t\treturn ALREADY_EXISTS;\n\t\t}\n\n\t\tif (name.length() < KEY_ID_LENGTH_MIN)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Profile name is too short, minimum is \" + KEY_ID_LENGTH_MIN);\n\t\t}\n\n\t\tif (name.length() > KEY_ID_LENGTH_MAX)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Profile name is too long, maximum is \" + KEY_ID_LENGTH_MAX);\n\t\t}\n\n\t\tlog.info(\"Generating PGP keys, algorithm: {}, bits: {} ...\", EXPERIMENTAL_EC ? \"EdDSA\" : \"RSA\", KEY_SIZE);\n\n\t\ttry\n\t\t{\n\t\t\tvar pgpSecretKey = PGP.generateSecretKey(name, KEY_ID_SUFFIX, KEY_SIZE);\n\t\t\tvar pgpPublicKey = pgpSecretKey.getPublicKey();\n\n\t\t\tlog.info(\"Successfully generated PGP key pair, id: {}\", Id.toString(pgpSecretKey.getKeyID()));\n\n\t\t\tcreateOwnProfile(name, pgpSecretKey, pgpPublicKey);\n\t\t\treturn CREATED;\n\t\t}\n\t\tcatch (PGPException | IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to generate PGP key pair\", e);\n\t\t}\n\t\treturn FAILED;\n\t}\n\n\t@Transactional\n\tpublic void createOwnProfile(String name, PGPSecretKey pgpSecretKey, PGPPublicKey pgpPublicKey) throws IOException\n\t{\n\t\tvar ownProfile = Profile.createOwnProfile(name, pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded());\n\t\tprofileRepository.save(ownProfile);\n\t\tsettingsService.saveSecretProfileKey(pgpSecretKey.getEncoded());\n\t}\n\n\tpublic Profile getOwnProfile()\n\t{\n\t\treturn profileRepository.findById(ProfileConstants.OWN_PROFILE_ID).orElseThrow(() -> new IllegalStateException(\"Missing own profile\"));\n\t}\n\n\tpublic boolean hasOwnProfile()\n\t{\n\t\treturn profileRepository.findById(ProfileConstants.OWN_PROFILE_ID).isPresent();\n\t}\n\n\tpublic Optional<Profile> findProfileById(long id)\n\t{\n\t\treturn profileRepository.findById(id);\n\t}\n\n\tpublic List<Profile> findProfilesByName(String name)\n\t{\n\t\treturn profileRepository.findAllByNameContaining(name);\n\t}\n\n\tpublic Optional<Profile> findProfileByPgpFingerprint(ProfileFingerprint profileFingerprint)\n\t{\n\t\treturn profileRepository.findByProfileFingerprint(profileFingerprint);\n\t}\n\n\tpublic Optional<Profile> findProfileByPgpIdentifier(long pgpIdentifier)\n\t{\n\t\treturn profileRepository.findByPgpIdentifier(pgpIdentifier);\n\t}\n\n\tpublic List<Profile> findAllCompleteProfilesByPgpIdentifiers(Set<Long> pgpIdentifiers)\n\t{\n\t\treturn profileRepository.findAllCompleteByPgpIdentifiers(pgpIdentifiers);\n\t}\n\n\tpublic Optional<Profile> findDiscoverableProfileByPgpIdentifier(long pgpIdentifier)\n\t{\n\t\treturn profileRepository.findDiscoverableProfileByPgpIdentifier(pgpIdentifier);\n\t}\n\n\tpublic List<Profile> findAllDiscoverableProfilesByPgpIdentifiers(Set<Long> pgpIdentifiers)\n\t{\n\t\treturn profileRepository.findAllDiscoverableProfilesByPgpIdentifiers(pgpIdentifiers);\n\t}\n\n\tpublic Optional<Profile> findProfileByLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\treturn profileRepository.findProfileByLocationIdentifier(locationIdentifier);\n\t}\n\n\tpublic ProfileKeyAttributes findProfileKeyAttributes(long id)\n\t{\n\t\tvar profile = findProfileById(id).orElseThrow();\n\n\t\ttry\n\t\t{\n\t\t\tvar publicKey = PGP.getPGPPublicKey(profile.getPgpPublicKeyData());\n\t\t\tif (publicKey.getSignatures().hasNext())\n\t\t\t{\n\t\t\t\tvar signature = publicKey.getSignatures().next();\n\t\t\t\treturn new ProfileKeyAttributes(\n\t\t\t\t\t\tpublicKey.getVersion(),\n\t\t\t\t\t\tpublicKey.getAlgorithm(),\n\t\t\t\t\t\tpublicKey.getBitStrength(),\n\t\t\t\t\t\tsignature.getHashAlgorithm()\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow new IllegalArgumentException(\"No signature present in the key\");\n\t\t}\n\t\tcatch (InvalidKeyException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"PGP public key for profile is invalid\");\n\t\t}\n\t}\n\n\t@Transactional\n\tpublic Profile createOrUpdateProfile(final Profile profile)\n\t{\n\t\tObjects.requireNonNull(profile);\n\n\t\tvar savedProfile = profileRepository.save(\n\t\t\t\tfindProfileByPgpFingerprint(profile.getProfileFingerprint())\n\t\t\t\t.map(foundProfile -> foundProfile.updateWith(profile))\n\t\t\t\t\t\t.orElse(profile)\n\t\t);\n\n\t\tcontactNotificationService.addOrUpdateProfile(savedProfile);\n\n\t\treturn savedProfile;\n\t}\n\n\tpublic Profile getProfileFromRSId(RSId rsId)\n\t{\n\t\tvar profile = findProfileByPgpFingerprint(rsId.getPgpFingerprint()).orElseGet(() -> createNewProfile(rsId));\n\t\tprofile.setAccepted(true);\n\t\tprofile.addLocation(Location.createLocation(rsId));\n\t\treturn profile;\n\t}\n\n\tprivate static Profile createNewProfile(RSId rsId)\n\t{\n\t\tif (rsId.getPgpPublicKey().isPresent())\n\t\t{\n\t\t\tvar pgpPublicKey = rsId.getPgpPublicKey().get();\n\t\t\treturn Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), rsId.getPgpFingerprint(), pgpPublicKey);\n\t\t}\n\t\treturn Profile.createEmptyProfile(rsId.getName(), rsId.getPgpIdentifier(), rsId.getPgpFingerprint());\n\t}\n\n\t@Transactional\n\tpublic void deleteProfile(long id)\n\t{\n\t\t// Make sure we don't automatically connect again while\n\t\t// the profile is being deleted\n\t\tvar profile = profileRepository.findById(id).orElseThrow();\n\t\tprofile.setAccepted(false);\n\n\t\tvar connectedLocations = profile.getLocations().stream()\n\t\t\t\t.filter(Location::isConnected)\n\t\t\t\t.toList();\n\n\t\t// If there's no connected locations, just delete the profile\n\t\t// and we're done. Otherwise, we need to disconnect the locations\n\t\t// and wait until that's done before deleting the profile.\n\t\tif (connectedLocations.isEmpty())\n\t\t{\n\t\t\tprofileRepository.delete(profile);\n\t\t\tcontactNotificationService.removeProfile(profile);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tprofilesToDelete.put(profile, connectedLocations.stream().map(Location::getLocationIdentifier).collect(Collectors.toSet()));\n\t\t\tvar ids = connectedLocations.stream().map(Location::getId).toList();\n\t\t\tids.forEach(location -> {\n\t\t\t\tvar peer = peerConnectionManager.getPeerByLocation(location);\n\t\t\t\tif (peer != null)\n\t\t\t\t{\n\t\t\t\t\tpeer.getCtx().close();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onPeerDisconnectedEvent(PeerDisconnectedEvent event)\n\t{\n\t\tif (profilesToDelete.isEmpty())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tprofilesToDelete.forEach((_, locationIdentifiers) -> locationIdentifiers.removeIf(locationId -> locationId.equals(event.locationIdentifier())));\n\t\tvar it = profilesToDelete.entrySet().iterator();\n\t\twhile (it.hasNext())\n\t\t{\n\t\t\tvar profileSetEntry = it.next();\n\t\t\tif (profileSetEntry.getValue().isEmpty())\n\t\t\t{\n\t\t\t\tprofileRepository.delete(profileSetEntry.getKey());\n\t\t\t\tcontactNotificationService.removeProfile(profileSetEntry.getKey());\n\t\t\t\tit.remove();\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic List<Profile> getAllProfiles()\n\t{\n\t\treturn profileRepository.findAll();\n\t}\n\n\tpublic List<Profile> getAllDiscoverableProfiles()\n\t{\n\t\treturn profileRepository.getAllDiscoverableProfiles();\n\t}\n\n\tpublic List<Profile> getAllProfilesIn(Set<Long> profileIds)\n\t{\n\t\treturn profileRepository.findAllById(profileIds);\n\t}\n\n\t@Transactional\n\tpublic void fixAllProfiles()\n\t{\n\t\tprofileRepository.findAll().forEach(profile -> {\n\t\t\tvar pgpPublicKeyData = profile.getPgpPublicKeyData();\n\t\t\tif (pgpPublicKeyData != null)\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tvar pgpPublicKey = PGP.getPGPPublicKey(pgpPublicKeyData);\n\t\t\t\t\tprofile.setCreated(pgpPublicKey.getCreationTime().toInstant());\n\t\t\t\t}\n\t\t\t\tcatch (InvalidKeyException _)\n\t\t\t\t{\n\t\t\t\t\t// Skip\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/QrCodeService.java",
    "content": "package io.xeres.app.service;\n\nimport com.google.zxing.BarcodeFormat;\nimport com.google.zxing.WriterException;\nimport com.google.zxing.client.j2se.MatrixToImageWriter;\nimport com.google.zxing.common.BitMatrix;\nimport com.google.zxing.qrcode.QRCodeWriter;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport java.awt.image.BufferedImage;\n\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\n\n@Service\npublic class QrCodeService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(QrCodeService.class);\n\n\tpublic BufferedImage generateQrCode(String message)\n\t{\n\t\tif (isEmpty(message))\n\t\t{\n\t\t\tlog.warn(\"No QR code to encode because the input is empty\");\n\t\t\treturn null;\n\t\t}\n\n\t\tvar qrCodeWriter = new QRCodeWriter();\n\t\tBitMatrix matrix;\n\t\ttry\n\t\t{\n\t\t\tmatrix = qrCodeWriter.encode(message, BarcodeFormat.QR_CODE, 256, 256);\n\t\t}\n\t\tcatch (WriterException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't generate QR Code: {}\", e.getMessage(), e);\n\t\t\treturn null;\n\t\t}\n\t\treturn MatrixToImageWriter.toBufferedImage(matrix);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/ResourceCreationState.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\npublic enum ResourceCreationState\n{\n\tCREATED,\n\tALREADY_EXISTS,\n\tFAILED\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/SettingsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.app.application.events.SettingsChangedEvent;\nimport io.xeres.app.database.model.settings.Settings;\nimport io.xeres.app.database.model.settings.SettingsMapper;\nimport io.xeres.app.database.repository.SettingsRepository;\nimport io.xeres.common.dto.settings.SettingsDTO;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.protocol.HostPort;\nimport io.xeres.common.util.RemoteUtils;\nimport jakarta.annotation.PostConstruct;\nimport jakarta.json.JsonPatch;\nimport jakarta.json.JsonValue;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport tools.jackson.databind.ObjectMapper;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.security.KeyPair;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.time.format.DateTimeFormatter;\nimport java.util.Comparator;\nimport java.util.Objects;\nimport java.util.regex.Pattern;\n\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Service\npublic class SettingsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SettingsService.class);\n\n\tprivate static final String BACKUP_FILE_PREFIX = \"backup_\";\n\tprivate static final String BACKUP_FILE_EXTENSION = \".zip\";\n\tprivate static final Pattern BACKUP_FILES = Pattern.compile(\"^backup_\\\\d{14}.zip$\");\n\tprivate static final int BACKUP_FILES_RETENTION = 3;\n\n\tprivate static final DateTimeFormatter backupFileFormatter = DateTimeFormatter.ofPattern(\"yyyyMMddHHmmss\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\tprivate final SettingsRepository settingsRepository;\n\n\tprivate final ApplicationEventPublisher publisher;\n\n\tprivate final ObjectMapper objectMapper;\n\n\tprivate final UiBridgeService uiBridgeService;\n\n\tprivate Settings settings;\n\n\tpublic SettingsService(SettingsRepository settingsRepository, ApplicationEventPublisher publisher, ObjectMapper objectMapper, UiBridgeService uiBridgeService)\n\t{\n\t\tthis.settingsRepository = settingsRepository;\n\t\tthis.publisher = publisher;\n\t\tthis.objectMapper = objectMapper;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t}\n\n\t@PostConstruct\n\tvoid init() // Keep as default access for testing\n\t{\n\t\tsettings = settingsRepository.findById((byte) 1).orElseThrow(() -> new IllegalStateException(\"No setting configuration\"));\n\n\t\tsetPasswordInClients();\n\t}\n\n\tprivate void setPasswordInClients()\n\t{\n\t\tvar remotePassword = getPasswordForClients();\n\t\tif (remotePassword != null)\n\t\t{\n\t\t\tuiBridgeService.setClientsAuthentication(\"user\", remotePassword);\n\t\t}\n\t}\n\n\tprivate String getPasswordForClients()\n\t{\n\t\tif (!StartupProperties.getBoolean(StartupProperties.Property.CONTROL_PASSWORD, true))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tString remotePassword = null;\n\n\t\tif (RemoteUtils.isRemoteUiClient())\n\t\t{\n\t\t\tremotePassword = StartupProperties.getString(StartupProperties.Property.REMOTE_PASSWORD);\n\t\t}\n\n\t\tif (remotePassword == null && hasRemotePassword())\n\t\t{\n\t\t\tremotePassword = getRemotePassword();\n\t\t}\n\t\treturn remotePassword;\n\t}\n\n\t/**\n\t * Performs a backup of the database.\n\t * <p>\n\t * The last {@code BACKUP_FILE_RETENTION} files are kept. The rest is deleted. A timestamp is placed within the name of each backup file.\n\t *\n\t * @param directory the directory in where to place the backup.\n\t */\n\tpublic void backup(String directory)\n\t{\n\t\tObjects.requireNonNull(directory);\n\n\t\tvar backupFile = Path.of(directory, BACKUP_FILE_PREFIX + backupFileFormatter.format(Instant.now()) + BACKUP_FILE_EXTENSION);\n\n\t\tlog.info(\"Doing backup of database to {}\", backupFile);\n\t\tsettingsRepository.backupDatabase(backupFile.toString());\n\t\tdeleteOldestBackupSiblings(backupFile);\n\t}\n\n\tprivate void deleteOldestBackupSiblings(Path file)\n\t{\n\t\ttry (var pathStream = Files.find(file.getParent(), 1, (path, attributes) -> BACKUP_FILES.matcher(path.getFileName().toString()).matches() && attributes.isRegularFile()))\n\t\t{\n\t\t\tpathStream.sorted(Comparator.comparing(path -> path.toFile().lastModified()))\n\t\t\t\t\t.sorted(Comparator.reverseOrder())\n\t\t\t\t\t.skip(BACKUP_FILES_RETENTION)\n\t\t\t\t\t.forEach(this::deleteFile);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tprivate void deleteFile(Path path)\n\t{\n\t\ttry\n\t\t{\n\t\t\tFiles.delete(path);\n\t\t}\n\t\tcatch (IOException _)\n\t\t{\n\t\t\tlog.error(\"Couldn't delete old backup file: {}\", path);\n\t\t}\n\t}\n\n\t/**\n\t * Retrieve the settings. For DTO use only.\n\t *\n\t * @return the settings as a DTO\n\t */\n\tpublic SettingsDTO getSettings()\n\t{\n\t\treturn SettingsMapper.toDTO(settings);\n\t}\n\n\t@Transactional\n\tpublic Settings applyPatchToSettings(JsonPatch jsonPatch)\n\t{\n\t\tvar source = objectMapper.convertValue(settings, JsonValue.class);\n\t\tvar patched = jsonPatch.apply(source.asJsonObject());\n\t\tupdateSettings(objectMapper.convertValue(patched, Settings.class));\n\t\treturn settings;\n\t}\n\n\t@Transactional\n\tpublic Settings applySettings(Settings newSettings)\n\t{\n\t\t// Those 5 are not transfered in the UI\n\t\tnewSettings.setPgpPrivateKeyData(settings.getPgpPrivateKeyData());\n\t\tnewSettings.setLocationPrivateKeyData(settings.getLocationPrivateKeyData());\n\t\tnewSettings.setLocationPublicKeyData(settings.getLocationPublicKeyData());\n\t\tnewSettings.setLocationCertificate(settings.getLocationCertificate());\n\t\tnewSettings.setLocalPort(settings.getLocalPort());\n\n\t\tupdateSettings(newSettings);\n\t\treturn newSettings;\n\t}\n\n\tprivate void updateSettings(Settings settings)\n\t{\n\t\tvar oldSettings = this.settings;\n\t\tthis.settings = settings;\n\t\tsettingsRepository.save(settings);\n\t\tpublisher.publishEvent(new SettingsChangedEvent(oldSettings, settings));\n\t}\n\n\t@Transactional\n\tpublic void saveSecretProfileKey(byte[] privateKeyData)\n\t{\n\t\tsettings.setPgpPrivateKeyData(privateKeyData);\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic byte[] getSecretProfileKey()\n\t{\n\t\treturn settings.getPgpPrivateKeyData();\n\t}\n\n\t@Transactional\n\tpublic void saveLocationKeys(KeyPair keyPair)\n\t{\n\t\tsettings.setLocationPrivateKeyData(keyPair.getPrivate().getEncoded());\n\t\tsettings.setLocationPublicKeyData(keyPair.getPublic().getEncoded());\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic byte[] getLocationPublicKeyData()\n\t{\n\t\treturn settings.getLocationPublicKeyData();\n\t}\n\n\tpublic byte[] getLocationPrivateKeyData()\n\t{\n\t\treturn settings.getLocationPrivateKeyData();\n\t}\n\n\t@Transactional\n\tpublic void saveLocationCertificate(byte[] data)\n\t{\n\t\tsettings.setLocationCertificate(data);\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic byte[] getLocationCertificate()\n\t{\n\t\treturn settings.getLocationCertificate();\n\t}\n\n\tpublic boolean hasOwnLocation()\n\t{\n\t\treturn settings.hasLocationCertificate();\n\t}\n\n\tpublic boolean isOwnProfilePresent()\n\t{\n\t\treturn settings.getPgpPrivateKeyData() != null;\n\t}\n\n\tpublic boolean hasTorSocksConfigured()\n\t{\n\t\treturn isNotBlank(settings.getTorSocksHost()) && settings.getTorSocksPort() != 0;\n\t}\n\n\tpublic HostPort getTorSocksHostPort()\n\t{\n\t\treturn new HostPort(settings.getTorSocksHost(), settings.getTorSocksPort());\n\t}\n\n\tpublic boolean hasI2pSocksConfigured()\n\t{\n\t\treturn isNotBlank(settings.getI2pSocksHost()) && settings.getI2pSocksPort() != 0;\n\t}\n\n\tpublic HostPort getI2pSocksHostPort()\n\t{\n\t\treturn new HostPort(settings.getI2pSocksHost(), settings.getI2pSocksPort());\n\t}\n\n\tpublic boolean isUpnpEnabled()\n\t{\n\t\treturn settings.isUpnpEnabled();\n\t}\n\n\tpublic boolean isBroadcastDiscoveryEnabled()\n\t{\n\t\treturn settings.isBroadcastDiscoveryEnabled();\n\t}\n\n\tpublic boolean isDhtEnabled()\n\t{\n\t\treturn settings.isDhtEnabled();\n\t}\n\n\tpublic int getLocalPort()\n\t{\n\t\treturn settings.getLocalPort();\n\t}\n\n\t@Transactional\n\tpublic void setLocalPort(int port)\n\t{\n\t\tsettings.setLocalPort(port);\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic boolean isAutoStartEnabled()\n\t{\n\t\treturn settings.isAutoStartEnabled();\n\t}\n\n\tpublic boolean hasIncomingDirectory()\n\t{\n\t\treturn StringUtils.isNotEmpty(settings.getIncomingDirectory());\n\t}\n\n\tpublic String getIncomingDirectory()\n\t{\n\t\treturn settings.getIncomingDirectory();\n\t}\n\n\t@Transactional\n\tpublic void setIncomingDirectory(String directory)\n\t{\n\t\tsettings.setIncomingDirectory(directory);\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic boolean hasRemotePassword()\n\t{\n\t\treturn StringUtils.isNotEmpty(settings.getRemotePassword());\n\t}\n\n\tpublic String getRemotePassword()\n\t{\n\t\treturn settings.getRemotePassword();\n\t}\n\n\t@Transactional\n\tpublic void setRemotePassword(String password)\n\t{\n\t\tsettings.setRemotePassword(password);\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic int getVersion()\n\t{\n\t\treturn settings.getVersion();\n\t}\n\n\t@Transactional\n\tpublic void setVersion(int version)\n\t{\n\t\tsettings.setVersion(version);\n\t\tsettingsRepository.save(settings);\n\t}\n\n\tpublic boolean isUpnpRemoteEnabled()\n\t{\n\t\treturn settings.isRemoteEnabled() && settings.isUpnpRemoteEnabled();\n\t}\n\n\tpublic boolean isRemoteEnabled()\n\t{\n\t\treturn settings.isRemoteEnabled();\n\t}\n\n\tpublic boolean hasRemotePortConfigured()\n\t{\n\t\treturn settings.isRemoteEnabled() && settings.getRemotePort() != 0;\n\t}\n\n\tpublic int getRemotePort()\n\t{\n\t\treturn settings.getRemotePort();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/UiBridgeService.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.common.tray.TrayNotificationType;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.support.splash.SplashService;\nimport io.xeres.ui.support.tray.TrayService;\nimport org.springframework.stereotype.Service;\nimport org.springframework.web.reactive.function.client.WebClient;\n\n/**\n * This class allows to call methods in the UI module. This (and XeresApplication) should be the only classes being able to do that.\n * This helps separate concerns as long as this class stays as small as possible.\n * There's an ArchUnit rule that finds violations.\n */\n@Service\npublic class UiBridgeService\n{\n\tpublic enum SplashStatus\n\t{\n\t\tDATABASE,\n\t\tNETWORK\n\t}\n\n\tprivate final SplashService splashService;\n\tprivate final TrayService trayService;\n\tprivate final WebClient.Builder webClientBuilder;\n\tprivate final MessageClient messageClient;\n\n\tpublic UiBridgeService(SplashService splashService, TrayService trayService, WebClient.Builder webClientBuilder, MessageClient messageClient)\n\t{\n\t\tthis.splashService = splashService;\n\t\tthis.trayService = trayService;\n\t\tthis.webClientBuilder = webClientBuilder;\n\t\tthis.messageClient = messageClient;\n\t}\n\n\tpublic void setSplashStatus(SplashStatus status)\n\t{\n\t\tsplashService.status(switch (status)\n\t\t{\n\t\t\tcase DATABASE -> SplashService.Status.DATABASE;\n\t\t\tcase NETWORK -> SplashService.Status.NETWORK;\n\t\t});\n\t}\n\n\tpublic void closeSplashScreen()\n\t{\n\t\tsplashService.close();\n\t}\n\n\tpublic void showTrayNotification(TrayNotificationType type, String message)\n\t{\n\t\ttrayService.showNotification(type, message);\n\t}\n\n\tpublic void setTrayStatus(String message)\n\t{\n\t\ttrayService.setTooltip(message);\n\t}\n\n\tpublic void setClientsAuthentication(String username, String password)\n\t{\n\t\twebClientBuilder.defaultHeaders(httpHeaders -> httpHeaders.setBasicAuth(username, password));\n\t\tmessageClient.setAuthentication(username, password);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/UnHtmlService.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport org.apache.commons.lang3.Strings;\nimport org.commonmark.ext.gfm.strikethrough.Strikethrough;\nimport org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;\nimport org.commonmark.node.*;\nimport org.commonmark.renderer.markdown.MarkdownRenderer;\nimport org.jsoup.Jsoup;\nimport org.jsoup.nodes.Element;\nimport org.jsoup.nodes.Node;\nimport org.jsoup.nodes.TextNode;\nimport org.jsoup.safety.Cleaner;\nimport org.jsoup.safety.Safelist;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.regex.Pattern;\n\nimport static org.apache.commons.lang3.StringUtils.isBlank;\n\n@Service\npublic class UnHtmlService\n{\n\tprivate static final Pattern WHITESPACE_PATTERN = Pattern.compile(\"\\\\s+\");\n\n\tprivate final MarkdownRenderer markdownRenderer;\n\n\tpublic UnHtmlService()\n\t{\n\t\tmarkdownRenderer = MarkdownRenderer.builder()\n\t\t\t\t.extensions(List.of(StrikethroughExtension.create()))\n\t\t\t\t.build();\n\t}\n\n\tpublic String cleanupMessage(String text)\n\t{\n\t\t// Only process HTML\n\t\tif (isBlank(text) ||\n\t\t\t\t(!Strings.CI.startsWith(text, \"<body>\") &&\n\t\t\t\t\t\t!Strings.CI.startsWith(text, \"<!DOCTYPE\") &&\n\t\t\t\t\t\t!Strings.CI.startsWith(text, \"<html>\") &&\n\t\t\t\t\t\t!Strings.CI.startsWith(text, \"<a \"))) // Also convert certificate links sent by Xeres to RS. One day we'll send them as Markdown too\n\t\t{\n\t\t\treturn text;\n\t\t}\n\n\t\tvar document = Jsoup.parse(text);\n\t\tvar cleaner = new Cleaner(Safelist.none()\n\t\t\t\t.addAttributes(\"img\", \"src\", \"title\", \"alt\")\n\t\t\t\t.addProtocols(\"img\", \"src\", \"data\")\n\t\t\t\t.addAttributes(\"a\", \"href\", \"title\")\n\t\t\t\t.addAttributes(\"code\", \"class\")\n\t\t\t\t.addTags(\"p\", \"br\", \"ul\", \"li\", \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\", \"hr\", \"blockquote\", \"ol\", \"em\", \"i\", \"strong\", \"b\", \"pre\", \"del\", \"s\")\n\t\t\t\t.preserveRelativeLinks(true));\n\n\t\tdocument = cleaner.clean(document);\n\n\t\treturn convertToMarkdown(document);\n\t}\n\n\tprivate String convertToMarkdown(org.jsoup.nodes.Document jsoupDocument)\n\t{\n\t\tvar commonMarkDocument = new org.commonmark.node.Document();\n\t\tString render;\n\n\t\ttry\n\t\t{\n\t\t\tconvertNodes(jsoupDocument.body().childNodes(), commonMarkDocument);\n\t\t\trender = markdownRenderer.render(commonMarkDocument);\n\t\t}\n\t\tcatch (IllegalArgumentException e)\n\t\t{\n\t\t\trender = \"## Invalid HTML document\\n\\n### Error\\n\\n\" + e.getMessage() + \"\\n\\n### Original message\\n\\n```html\" + jsoupDocument.body().html() + \"\\n```\\n\";\n\t\t}\n\t\treturn render;\n\t}\n\n\tprivate static void convertNodes(List<Node> jsoupNodes, org.commonmark.node.Node commonMarkParent)\n\t{\n\t\tfor (Node jsoupNode : jsoupNodes)\n\t\t{\n\t\t\tif (jsoupNode instanceof TextNode textNode)\n\t\t\t{\n\t\t\t\tString text = textNode.text();\n\t\t\t\tif (!text.trim().isEmpty())\n\t\t\t\t{\n\t\t\t\t\tcommonMarkParent.appendChild(new Text(text));\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if (jsoupNode instanceof Element element)\n\t\t\t{\n\t\t\t\tvar commonMarkNode = createCommonMarkNode(element);\n\n\t\t\t\tif (commonMarkNode != null)\n\t\t\t\t{\n\t\t\t\t\tcommonMarkParent.appendChild(commonMarkNode);\n\t\t\t\t\t// Recursively convert child nodes\n\t\t\t\t\tconvertNodes(element.childNodes(), commonMarkNode);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t// If the element isn't converted, just convert its children\n\t\t\t\t\tconvertNodes(element.childNodes(), commonMarkParent);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static org.commonmark.node.Node createCommonMarkNode(Element element)\n\t{\n\t\tString tagName = element.tagName().toLowerCase();\n\n\t\treturn switch (tagName)\n\t\t{\n\t\t\tcase \"h1\" -> createHeading(1);\n\t\t\tcase \"h2\" -> createHeading(2);\n\t\t\tcase \"h3\" -> createHeading(3);\n\t\t\tcase \"h4\" -> createHeading(4);\n\t\t\tcase \"h5\" -> createHeading(5);\n\t\t\tcase \"h6\" -> createHeading(6);\n\t\t\tcase \"p\" -> new Paragraph();\n\t\t\tcase \"br\" -> new HardLineBreak();\n\t\t\tcase \"hr\" -> new ThematicBreak();\n\t\t\tcase \"blockquote\" -> new BlockQuote();\n\t\t\tcase \"ul\" -> new BulletList();\n\t\t\tcase \"ol\" -> new OrderedList();\n\t\t\tcase \"li\" -> new ListItem();\n\t\t\tcase \"em\", \"i\" -> new Emphasis();\n\t\t\tcase \"strong\", \"b\" -> new StrongEmphasis();\n\t\t\tcase \"s\", \"del\" -> new Strikethrough(\"~\");\n\t\t\tcase \"code\" ->\n\t\t\t{\n\t\t\t\tif (element.parent() != null && \"pre\".equals(element.parent().tagName().toLowerCase(Locale.ROOT)))\n\t\t\t\t{\n\t\t\t\t\t// The code block is handled by the \"pre\" element\n\t\t\t\t\tyield null;\n\t\t\t\t}\n\t\t\t\tvar code = new Code();\n\t\t\t\tif (element.childNodeSize() == 1)\n\t\t\t\t{\n\t\t\t\t\tvar node = element.childNode(0);\n\t\t\t\t\tif (node instanceof TextNode textNode)\n\t\t\t\t\t{\n\t\t\t\t\t\t// Code doesn't handle children\n\t\t\t\t\t\tcode.setLiteral(textNode.text());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tyield code;\n\t\t\t}\n\t\t\tcase \"pre\" ->\n\t\t\t{\n\t\t\t\tvar codeBlock = new FencedCodeBlock();\n\t\t\t\t// Try to detect language\n\t\t\t\tif (element.childrenSize() == 1 && \"code\".equals(element.child(0).tagName().toLowerCase(Locale.ROOT)))\n\t\t\t\t{\n\t\t\t\t\tString classNames = element.child(0).className();\n\t\t\t\t\tif (!classNames.isEmpty())\n\t\t\t\t\t{\n\t\t\t\t\t\tString language = WHITESPACE_PATTERN.split(classNames)[0];\n\t\t\t\t\t\tcodeBlock.setInfo(language.replace(\"language-\", \"\"));\n\t\t\t\t\t}\n\t\t\t\t\tsetFencedCodeBlockLiteralIfFound(codeBlock, element.child(0));\n\t\t\t\t}\n\t\t\t\tsetFencedCodeBlockLiteralIfFound(codeBlock, element);\n\t\t\t\tyield codeBlock;\n\t\t\t}\n\t\t\tcase \"a\" ->\n\t\t\t{\n\t\t\t\tvar link = new Link();\n\t\t\t\tlink.setDestination(element.attr(\"href\"));\n\t\t\t\tlink.setTitle(element.attr(\"title\"));\n\t\t\t\tyield link;\n\t\t\t}\n\t\t\tcase \"img\" ->\n\t\t\t{\n\t\t\t\tvar image = new Image();\n\t\t\t\timage.setDestination(element.attr(\"src\"));\n\t\t\t\timage.setTitle(element.attr(\"title\"));\n\t\t\t\tvar altText = new Text(element.attr(\"alt\"));\n\t\t\t\timage.appendChild(altText);\n\t\t\t\tyield image;\n\t\t\t}\n\t\t\tdefault -> null; // For unsupported elements, return null to skip but still process children\n\t\t};\n\t}\n\n\tprivate static void setFencedCodeBlockLiteralIfFound(FencedCodeBlock codeBlock, Element element)\n\t{\n\t\tif (element.childNodeSize() == 1)\n\t\t{\n\t\t\tvar node = element.childNode(0);\n\t\t\tif (node instanceof TextNode textNode)\n\t\t\t{\n\t\t\t\t// FencedCodeBlock doesn't handle children\n\t\t\t\tcodeBlock.setLiteral(textNode.text());\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tif (codeBlock.getLiteral() == null)\n\t\t{\n\t\t\tcodeBlock.setLiteral(\"\");\n\t\t}\n\t}\n\n\tprivate static Heading createHeading(int level)\n\t{\n\t\tvar heading = new Heading();\n\t\theading.setLevel(level);\n\t\treturn heading;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/UpgradeService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.configuration.DataDirConfiguration;\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.app.database.model.share.Share;\nimport io.xeres.app.service.file.FileService;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.util.SecureRandomUtils;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Arrays;\n\n@Service\npublic class UpgradeService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UpgradeService.class);\n\n\tprivate static final String INCOMING_DIRECTORY_NAME = \"Incoming\";\n\tprivate static final String STICKERS_DIRECTORY_NAME = \"Stickers\";\n\n\tprivate final DataDirConfiguration dataDirConfiguration;\n\tprivate final SettingsService settingsService;\n\tprivate final FileService fileService;\n\tprivate final IdentityRsService identityRsService;\n\tprivate final ProfileService profileService;\n\n\tpublic UpgradeService(DataDirConfiguration dataDirConfiguration, SettingsService settingsService, FileService fileService, IdentityRsService identityRsService, ProfileService profileService)\n\t{\n\t\tthis.dataDirConfiguration = dataDirConfiguration;\n\t\tthis.settingsService = settingsService;\n\t\tthis.fileService = fileService;\n\t\tthis.identityRsService = identityRsService;\n\t\tthis.profileService = profileService;\n\t}\n\n\t/**\n\t * Configures defaults and upgrades that cannot be done on the database definition alone because\n\t * they depend on some runtime parameters. This is not called in UI client only mode.\n\t */\n\tpublic void upgrade()\n\t{\n\t\tvar version = 5; // Increment this number when needing to add new defaults\n\n\t\t// Don't do this stuff when running tests\n\t\tif (dataDirConfiguration.getDataDir() == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (!settingsService.hasIncomingDirectory())\n\t\t{\n\t\t\tvar incomingDirectory = Path.of(dataDirConfiguration.getDataDir(), INCOMING_DIRECTORY_NAME);\n\t\t\tif (Files.notExists(incomingDirectory))\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tFiles.createDirectory(incomingDirectory);\n\t\t\t\t}\n\t\t\t\tcatch (IOException e)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalStateException(\"Couldn't create incoming directory: \" + incomingDirectory + \", :\" + e.getMessage());\n\t\t\t\t}\n\t\t\t}\n\t\t\tsettingsService.setIncomingDirectory(incomingDirectory.toString());\n\t\t\tfileService.addShare(Share.createShare(INCOMING_DIRECTORY_NAME, File.createFile(incomingDirectory), false, Trust.UNKNOWN));\n\t\t}\n\n\t\tif (settingsService.getVersion() < 1)\n\t\t{\n\t\t\tvar password = new char[20];\n\t\t\tSecureRandomUtils.nextPassword(password);\n\t\t\tsettingsService.setRemotePassword(String.valueOf(password));\n\t\t\tArrays.fill(password, (char) 0);\n\t\t}\n\n\t\tif (settingsService.getVersion() < 2)\n\t\t{\n\t\t\tfileService.encryptAllHashes();\n\t\t}\n\n\t\tif (settingsService.getVersion() < 3)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tidentityRsService.fixOwnProfile();\n\t\t\t}\n\t\t\tcatch (PGPException | IOException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Couldn't fix own profile hash + signature: \" + e.getMessage());\n\t\t\t}\n\t\t\tprofileService.fixAllProfiles();\n\t\t}\n\n\t\tif (settingsService.getVersion() < 4)\n\t\t{\n\t\t\tvar stickersDirectory = Path.of(dataDirConfiguration.getDataDir(), STICKERS_DIRECTORY_NAME);\n\t\t\tif (Files.notExists(stickersDirectory))\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tFiles.createDirectory(stickersDirectory);\n\t\t\t\t}\n\t\t\t\tcatch (IOException e)\n\t\t\t\t{\n\t\t\t\t\t// Not very important, we can live without stickers.\n\t\t\t\t\tlog.error(\"Couldn't create stickers directory: {}, {}. Stickers won't be available\", stickersDirectory, e.getMessage());\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (settingsService.getVersion() < 5)\n\t\t{\n\t\t\t// Removing the service string will change the identity's signature,\n\t\t\t// so we need to recompute it again.\n\t\t\tidentityRsService.fixOwnIdentity();\n\t\t}\n\n\t\t// [Add new defaults here]\n\n\t\tsettingsService.setVersion(version);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/audio/AudioService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.audio;\n\nimport io.xeres.common.util.ThreadUtils;\nimport org.springframework.stereotype.Service;\n\nimport javax.sound.sampled.*;\nimport java.io.ByteArrayOutputStream;\nimport java.util.function.Consumer;\nimport java.util.function.Supplier;\n\n@Service\npublic class AudioService\n{\n\tprivate static final int AUDIO_SAMPLE_RATE = 16000; // in Hz, wideband\n\tprivate static final int AUDIO_SAMPLE_SIZE = 16; // in bits\n\tprivate static final int AUDIO_SAMPLE_CHANNELS = 1; // mono\n\n\tprivate TargetDataLine inputLine;\n\tprivate SourceDataLine outputLine;\n\tprivate volatile boolean isRecording;\n\tprivate volatile boolean isPlaying;\n\tprivate Thread recordThread;\n\tprivate Thread playThread;\n\tprivate ByteArrayOutputStream audioBuffer;\n\tprivate Consumer<byte[]> audioConsumer;\n\tprivate Supplier<byte[]> audioSupplier;\n\n\tprivate AudioFormat audioFormat;\n\tprivate int frameSize;\n\n\tpublic int getAudioSampleRate()\n\t{\n\t\treturn AUDIO_SAMPLE_RATE;\n\t}\n\n\tpublic int getAudioSampleSize()\n\t{\n\t\treturn AUDIO_SAMPLE_SIZE;\n\t}\n\n\tpublic int getAudioSampleChannels()\n\t{\n\t\treturn AUDIO_SAMPLE_CHANNELS;\n\t}\n\n\t@SuppressWarnings(\"DataFlowIssue\")\n\tpublic int getSpeexEncoderMode()\n\t{\n\t\treturn switch (AUDIO_SAMPLE_RATE)\n\t\t{\n\t\t\tcase 8000 -> 0;\n\t\t\tcase 16000 -> 1;\n\t\t\tcase 32000 -> 2;\n\t\t\tdefault -> throw new IllegalStateException(\"Wrong sample rate \" + AUDIO_SAMPLE_RATE + \", must be 8000, 16000 or 32000\");\n\t\t};\n\t}\n\n\tpublic void startPlayingAndRecording(int frameSize, Consumer<byte[]> audioConsumer, Supplier<byte[]> audioSupplier)\n\t{\n\t\tstartPlaying(audioSupplier);\n\t\tstartRecording(frameSize, audioConsumer);\n\t}\n\n\tpublic void stopRecordingAndPlaying()\n\t{\n\t\tstopRecording();\n\t\tstopPlaying();\n\t}\n\n\tprivate void startRecording(int frameSize, Consumer<byte[]> audioConsumer)\n\t{\n\t\tcreateAudioFormatIfNeeded();\n\n\t\ttry\n\t\t{\n\t\t\tinputLine = AudioSystem.getTargetDataLine(audioFormat);\n\t\t\tinputLine.open();\n\n\t\t\taudioBuffer = new ByteArrayOutputStream();\n\t\t\tthis.frameSize = frameSize;\n\t\t\tthis.audioConsumer = audioConsumer;\n\t\t\tisRecording = true;\n\n\t\t\tinputLine.start();\n\n\t\t\trecordThread = Thread.ofVirtual()\n\t\t\t\t\t.name(\"Audio Capture Service\")\n\t\t\t\t\t.start(this::captureAudio);\n\t\t}\n\t\tcatch (LineUnavailableException | IllegalArgumentException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Audio capture device not available: \" + e.getMessage());\n\t\t}\n\t}\n\n\tprivate void stopRecording()\n\t{\n\t\tisRecording = false;\n\t\tThreadUtils.waitForThread(recordThread);\n\n\t\tif (inputLine != null)\n\t\t{\n\t\t\tinputLine.stop();\n\t\t\tinputLine.close();\n\t\t}\n\t}\n\n\tprivate void startPlaying(Supplier<byte[]> audioSupplier)\n\t{\n\t\tcreateAudioFormatIfNeeded();\n\n\t\ttry\n\t\t{\n\t\t\toutputLine = AudioSystem.getSourceDataLine(audioFormat);\n\t\t\toutputLine.open();\n\n\t\t\tthis.audioSupplier = audioSupplier;\n\t\t\tisPlaying = true;\n\n\t\t\toutputLine.start();\n\n\t\t\tplayThread = Thread.ofVirtual()\n\t\t\t\t\t.name(\"Audio Playing Service\")\n\t\t\t\t\t.start(this::playAudio);\n\t\t}\n\t\tcatch (LineUnavailableException | IllegalArgumentException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Audio playing device not available: \" + e.getMessage());\n\t\t}\n\t}\n\n\tprivate void stopPlaying()\n\t{\n\t\tisPlaying = false;\n\t\tThreadUtils.waitForThread(playThread);\n\n\t\tif (outputLine != null)\n\t\t{\n\t\t\toutputLine.stop();\n\t\t\toutputLine.close();\n\t\t}\n\t}\n\n\tprivate void createAudioFormatIfNeeded()\n\t{\n\t\tif (audioFormat == null)\n\t\t{\n\t\t\taudioFormat = new AudioFormat(AUDIO_SAMPLE_RATE, AUDIO_SAMPLE_SIZE, AUDIO_SAMPLE_CHANNELS, true, false);\n\t\t}\n\t}\n\n\tprivate void captureAudio()\n\t{\n\t\tvar buffer = new byte[frameSize * AUDIO_SAMPLE_CHANNELS * (AUDIO_SAMPLE_SIZE / 8)];\n\t\tint bytesRead;\n\n\t\twhile (isRecording)\n\t\t{\n\t\t\tbytesRead = inputLine.read(buffer, 0, buffer.length);\n\t\t\tif (bytesRead == buffer.length) // Only use full buffers, otherwise that's not enough to process a frame and the encoder will complain\n\t\t\t{\n\t\t\t\taudioBuffer.reset();\n\t\t\t\taudioBuffer.write(buffer, 0, bytesRead);\n\n\t\t\t\taudioConsumer.accept(audioBuffer.toByteArray());\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void playAudio()\n\t{\n\t\twhile (isPlaying)\n\t\t{\n\t\t\tvar buffer = audioSupplier.get();\n\t\t\toutputLine.write(buffer, 0, buffer.length);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/BackupService.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.crypto.rsid.RSId;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.util.XmlUtils;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.rest.config.ImportRsFriendsResponse;\nimport io.xeres.common.rsid.Type;\nimport jakarta.xml.bind.JAXBContext;\nimport jakarta.xml.bind.JAXBException;\nimport jakarta.xml.bind.Marshaller;\nimport jakarta.xml.bind.helpers.DefaultValidationEventHandler;\nimport org.apache.commons.lang3.StringUtils;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPKeyPair;\nimport org.bouncycastle.openpgp.PGPUtil;\nimport org.bouncycastle.openpgp.jcajce.JcaPGPSecretKeyRingCollection;\nimport org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder;\nimport org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport javax.xml.stream.XMLStreamException;\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.security.InvalidKeyException;\nimport java.security.KeyPair;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SignatureException;\nimport java.security.cert.CertificateException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * Handles exporting and importing of profiles and friends, including importing from Retroshare.\n */\n@Service\npublic class BackupService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(BackupService.class);\n\n\tprivate static final long BACKUP_MAX_SIZE = 1024 * 1024 * 100L; // 100 MB\n\tprivate static final long RS_PROFILE_MAX_SIZE = (long) 1024 * 1024; // 1 MB\n\tprivate static final long RS_FRIENDS_MAX_SIZE = 1024 * 1024 * 10L; // 10 MB\n\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final IdentityService identityService;\n\tprivate final IdentityRsService identityRsService;\n\tprivate final SettingsService settingsService;\n\n\tpublic BackupService(ProfileService profileService, LocationService locationService, IdentityService identityService, IdentityRsService identityRsService, SettingsService settingsService)\n\t{\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.identityService = identityService;\n\t\tthis.identityRsService = identityRsService;\n\t\tthis.settingsService = settingsService;\n\t}\n\n\tpublic byte[] backup() throws JAXBException\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\n\t\tvar export = new Export();\n\t\tvar local = new Local();\n\t\tlocal.setProfile(new Profile(settingsService.getSecretProfileKey()));\n\t\tlocal.setLocation(new Location(locationService.findOwnLocation().orElseThrow().getLocationIdentifier(),\n\t\t\t\tsettingsService.getLocationPrivateKeyData(),\n\t\t\t\tsettingsService.getLocationPublicKeyData(),\n\t\t\t\tsettingsService.getLocationCertificate(),\n\t\t\t\tsettingsService.getLocalPort()));\n\n\t\tvar identityGroupItem = identityService.getOwnIdentity();\n\t\tlocal.setIdentity(new Identity(identityGroupItem.getName(), identityGroupItem.getAdminPrivateKey().getEncoded(), identityGroupItem.getAdminPublicKey().getEncoded()));\n\n\t\texport.setProfiles(profileService.getAllDiscoverableProfiles());\n\t\texport.setLocal(local);\n\n\t\tJAXBContext context;\n\t\tcontext = JAXBContext.newInstance(Export.class);\n\n\t\tvar marshaller = context.createMarshaller();\n\t\tmarshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);\n\n\t\tmarshaller.marshal(export, out);\n\t\treturn out.toByteArray();\n\t}\n\n\t@Transactional\n\tpublic void restore(MultipartFile file) throws JAXBException, IOException, InvalidKeyException, NoSuchAlgorithmException, InvalidKeySpecException, CertificateException, PGPException, XMLStreamException\n\t{\n\t\tif (file == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"XML backup file is empty\");\n\t\t}\n\n\t\tif (file.getSize() >= BACKUP_MAX_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"XML backup size is bigger than \" + BACKUP_MAX_SIZE + \" bytes\");\n\t\t}\n\n\t\tJAXBContext context;\n\t\tcontext = JAXBContext.newInstance(Export.class);\n\t\tvar xmlInputFactory = XmlUtils.getSecureXMLInputFactory();\n\n\t\tvar unmarshaller = context.createUnmarshaller();\n\t\tvar input = xmlInputFactory.createXMLStreamReader(file.getInputStream());\n\n\t\tvar export = (Export) unmarshaller.unmarshal(input);\n\n\t\tvar localProfile = export.getProfiles().stream()\n\t\t\t\t.filter(profile -> profile.getTrust() == Trust.ULTIMATE)\n\t\t\t\t.findFirst().orElseThrow(() -> new IllegalArgumentException(\"No local profile in the profile list\"));\n\n\t\tvar localLocationIdentifier = export.getLocal().getLocation().getLocationIdentifier();\n\t\tvar localLocation = localProfile.getLocations().stream()\n\t\t\t\t.filter(location -> location.getLocationIdentifier().equals(localLocationIdentifier))\n\t\t\t\t.findFirst().orElseThrow(); // XXX: if not found, create new location? should be allowed\n\n\t\tcreateOwnProfile(localProfile.getName(), export.getLocal().getProfile().getPgpPrivateKey(), localProfile.getPgpPublicKeyData());\n\t\tcreateOwnLocation(localLocation.getName(), export.getLocal().getLocation().getPrivateKey(), export.getLocal().getLocation().getPublicKey(), export.getLocal().getLocation().getX509Certificate());\n\t\tcreateOwnIdentity(export.getLocal().getIdentity().getName(), export.getLocal().getIdentity().getPrivateKey(), export.getLocal().getIdentity().getPublicKey());\n\n\t\tcreateProfiles(export.getProfiles());\n\t}\n\n\t@Transactional\n\tpublic void importProfileFromRs(MultipartFile file, String locationName, String password)\n\t{\n\t\tif (file == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"RS keyring is empty\");\n\t\t}\n\n\t\tif (file.getSize() >= RS_PROFILE_MAX_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"RS keyring is too big\");\n\t\t}\n\n\t\tif (StringUtils.isEmpty(locationName))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Location name is empty\");\n\t\t}\n\n\t\tif (StringUtils.isEmpty(password))\n\t\t{\n\t\t\tpassword = \"\";\n\t\t}\n\n\t\tString profileName;\n\n\t\ttry (var inputStream = getInputStream(file))\n\t\t{\n\t\t\tvar secretRingCollection = new JcaPGPSecretKeyRingCollection(inputStream);\n\t\t\tvar secretRing = secretRingCollection.getKeyRings().next();\n\t\t\tvar secretKey = secretRing.getSecretKey();\n\n\t\t\tvar digestCalculator = new JcaPGPDigestCalculatorProviderBuilder().build();\n\t\t\tvar keyDecryptor = new JcePBESecretKeyDecryptorBuilder(digestCalculator);\n\n\t\t\tvar id = secretKey.getPublicKey().getUserIDs().next();\n\t\t\tprofileName = cleanupProfileName(id);\n\n\t\t\tPGPKeyPair keyPair;\n\n\t\t\t// Decrypt\n\t\t\ttry\n\t\t\t{\n\t\t\t\tkeyPair = secretKey.extractKeyPair(keyDecryptor.build(password.toCharArray()));\n\t\t\t}\n\t\t\tcatch (PGPException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Wrong password\", e);\n\t\t\t}\n\n\t\t\t// End encrypt again with an empty password because we use a different security model\n\t\t\tvar newSecretKey = PGP.encryptKeyPair(keyPair, id);\n\n\t\t\tcreateOwnProfile(profileName,\n\t\t\t\t\tnewSecretKey.getEncoded(),\n\t\t\t\t\tnewSecretKey.getPublicKey().getEncoded());\n\t\t}\n\t\tcatch (PGPException | InvalidKeyException | IOException e)\n\t\t{\n\t\t\tlog.error(\"Error while parsing PGP data\", e);\n\t\t\tthrow new IllegalArgumentException(e);\n\t\t}\n\n\t\tlocationService.generateOwnLocation(locationName);\n\t\tidentityRsService.generateOwnIdentity(profileName, true);\n\t}\n\n\t@Transactional\n\tpublic ImportRsFriendsResponse importFriendsFromRs(MultipartFile file) throws JAXBException, IOException, XMLStreamException\n\t{\n\t\tif (file == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Friends file is empty\");\n\t\t}\n\n\t\tif (file.getSize() >= RS_FRIENDS_MAX_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Friends file is too large\");\n\t\t}\n\n\t\tJAXBContext context;\n\t\tcontext = JAXBContext.newInstance(Root.class);\n\n\t\tvar unmarshaller = context.createUnmarshaller();\n\t\tunmarshaller.setEventHandler(new DefaultValidationEventHandler()); // Display better error messages\n\n\t\tvar xmlInputFactory = XmlUtils.getSecureXMLInputFactory();\n\n\t\tvar input = xmlInputFactory.createXMLStreamReader(file.getInputStream());\n\n\t\tvar root = (Root) unmarshaller.unmarshal(input);\n\n\t\tvar certificates = root.getPgpIDs().stream()\n\t\t\t\t.map(PgpId::getSslIDs)\n\t\t\t\t.flatMap(Collection::stream)\n\t\t\t\t.map(SslId::getCertificate)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.toList();\n\n\t\tvar success = 0;\n\t\tvar errors = 0;\n\n\t\tfor (var certificate : certificates)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tRSId.parse(certificate, Type.CERTIFICATE).ifPresent(rsId -> profileService.createOrUpdateProfile(profileService.getProfileFromRSId(rsId)));\n\t\t\t\tsuccess++;\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t{\n\t\t\t\tlog.error(\"Error while adding friend {}\", certificate, e);\n\t\t\t\terrors++;\n\t\t\t}\n\t\t}\n\t\treturn new ImportRsFriendsResponse(success, errors);\n\t}\n\n\tpublic boolean verifyUpdate(Path updateFile, byte[] signature)\n\t{\n\t\ttry\n\t\t{\n\t\t\tPGP.verify(PGP.getUpdateSigningKey(), signature, Files.newInputStream(updateFile));\n\t\t\treturn true;\n\t\t}\n\t\tcatch (PGPException | IOException | SignatureException e)\n\t\t{\n\t\t\tlog.error(\"Error while verifying update {}\", e.getMessage());\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate static InputStream getInputStream(MultipartFile file) throws IOException\n\t{\n\n\t\tif (Objects.requireNonNull(file.getOriginalFilename()).endsWith(\".asc\"))\n\t\t{\n\t\t\t// Skip the PGP public key block because we don't need it, and\n\t\t\t// it gives problems for Bouncy Castle which can't read it for some reason\n\t\t\ttry (var in = new BufferedReader(new InputStreamReader(file.getInputStream())))\n\t\t\t{\n\t\t\t\tString line;\n\t\t\t\twhile ((line = readRsLine(in)) != null)\n\t\t\t\t{\n\t\t\t\t\tif (line.equals(\"-----END PGP PUBLIC KEY BLOCK-----\"))\n\t\t\t\t\t{\n\t\t\t\t\t\treadRsLine(in); // Skip the empty line before the next private key block\n\t\t\t\t\t\tvar out = new ByteArrayOutputStream();\n\t\t\t\t\t\tvar writer = new OutputStreamWriter(out);\n\t\t\t\t\t\twhile ((line = readRsLine(in)) != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\twriter.write(line + \"\\r\\n\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\twriter.close();\n\t\t\t\t\t\treturn PGPUtil.getDecoderStream(new ByteArrayInputStream(out.toByteArray()));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn PGPUtil.getDecoderStream(file.getInputStream());\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Retroshare uses \\r\\r\\n (mostly) instead of \\r\\n for line endings. This makes readLine() read\n\t * an extra line. This method fixes it by returning only one line ending.\n\t *\n\t * @param reader the BufferedReader\n\t * @return one line\n\t * @throws IOException when there's an I/O error\n\t */\n\tprivate static String readRsLine(BufferedReader reader) throws IOException\n\t{\n\t\tvar line = reader.readLine();\n\t\treader.mark(512);\n\t\tif (line == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar lineToSkip = reader.readLine();\n\t\tif (lineToSkip == null || !lineToSkip.isEmpty())\n\t\t{\n\t\t\treader.reset();\n\t\t}\n\t\treturn line;\n\t}\n\n\tprivate String cleanupProfileName(String profileName)\n\t{\n\t\treturn profileName.replace(\" (Generated by RetroShare) <>\", \"\");\n\t}\n\n\tprivate void createOwnProfile(String name, byte[] privateKey, byte[] publicKey) throws InvalidKeyException, IOException\n\t{\n\t\tvar pgpSecretKey = PGP.getPGPSecretKey(privateKey);\n\t\tvar pgpPublicKey = PGP.getPGPPublicKey(publicKey);\n\t\tprofileService.createOwnProfile(name, pgpSecretKey, pgpPublicKey);\n\t}\n\n\tprivate void createOwnLocation(String name, byte[] privateKey, byte[] publicKey, byte[] x509Certificate) throws NoSuchAlgorithmException, InvalidKeySpecException, CertificateException\n\t{\n\t\tvar keyPair = new KeyPair(RSA.getPublicKey(publicKey), RSA.getPrivateKey(privateKey));\n\t\tlocationService.createOwnLocation(name, keyPair, x509Certificate);\n\t}\n\n\tprivate void createOwnIdentity(String name, byte[] privateKey, byte[] publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException, PGPException, IOException\n\t{\n\t\tvar keyPair = new KeyPair(RSA.getPublicKey(publicKey), RSA.getPrivateKey(privateKey));\n\t\tidentityRsService.createOwnIdentity(name, keyPair);\n\t}\n\n\tprivate void createProfiles(List<io.xeres.app.database.model.profile.Profile> profiles) throws InvalidKeyException\n\t{\n\t\tfor (io.xeres.app.database.model.profile.Profile profile : profiles)\n\t\t{\n\t\t\tif (profile.getTrust() != Trust.ULTIMATE)\n\t\t\t{\n\t\t\t\tvar pgpPublicKey = PGP.getPGPPublicKey(profile.getPgpPublicKeyData());\n\t\t\t\tvar createdProfile = io.xeres.app.database.model.profile.Profile.createProfile(\n\t\t\t\t\t\tprofile.getName(), profile.getPgpIdentifier(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey);\n\t\t\t\tprofile.getLocations().forEach(createdProfile::addLocation);\n\t\t\t\tcreatedProfile.setAccepted(true);\n\t\t\t\tprofileService.createOrUpdateProfile(createdProfile);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Export.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport io.xeres.app.database.model.profile.Profile;\nimport jakarta.xml.bind.annotation.XmlElement;\nimport jakarta.xml.bind.annotation.XmlElementWrapper;\nimport jakarta.xml.bind.annotation.XmlRootElement;\n\nimport java.util.List;\n\n@XmlRootElement\nclass Export\n{\n\tprivate List<Profile> profiles;\n\tprivate Local local;\n\n\tpublic Export()\n\t{\n\t\t// Default constructor\n\t}\n\n\t@XmlElementWrapper\n\t@XmlElement(name = \"profile\")\n\tpublic List<Profile> getProfiles()\n\t{\n\t\treturn profiles;\n\t}\n\n\tpublic void setProfiles(List<Profile> profiles)\n\t{\n\t\tthis.profiles = profiles;\n\t}\n\n\tpublic Local getLocal()\n\t{\n\t\treturn local;\n\t}\n\n\tpublic void setLocal(Local local)\n\t{\n\t\tthis.local = local;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Group.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport jakarta.xml.bind.annotation.XmlElement;\n\nimport java.util.List;\n\nclass Group\n{\n\tprivate List<PgpId> pgpIDs;\n\n\tpublic Group()\n\t{\n\t\t// Default constructor\n\t}\n\n\t@XmlElement(name = \"pgpID\")\n\tpublic List<PgpId> getPgpIDs()\n\t{\n\t\treturn pgpIDs;\n\t}\n\n\tpublic void setPgpIDs(List<PgpId> pgpIDs)\n\t{\n\t\tthis.pgpIDs = pgpIDs;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Identity.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport jakarta.xml.bind.annotation.XmlAttribute;\n\nclass Identity\n{\n\tprivate String name;\n\tprivate byte[] privateKey;\n\tprivate byte[] publicKey;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic Identity()\n\t{\n\t\t// Default constructor\n\t}\n\n\tpublic Identity(String name, byte[] privateKey, byte[] publicKey)\n\t{\n\t\tthis.name = name;\n\t\tthis.privateKey = privateKey;\n\t\tthis.publicKey = publicKey;\n\t}\n\n\t@XmlAttribute\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPrivateKey()\n\t{\n\t\treturn privateKey;\n\t}\n\n\tpublic void setPrivateKey(byte[] privateKey)\n\t{\n\t\tthis.privateKey = privateKey;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPublicKey()\n\t{\n\t\treturn publicKey;\n\t}\n\n\tpublic void setPublicKey(byte[] publicKey)\n\t{\n\t\tthis.publicKey = publicKey;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Local.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nclass Local\n{\n\tprivate Profile profile;\n\tprivate Location location;\n\tprivate Identity identity;\n\n\tpublic Local()\n\t{\n\t\t// Default constructor\n\t}\n\n\tpublic Profile getProfile()\n\t{\n\t\treturn profile;\n\t}\n\n\tpublic void setProfile(Profile profile)\n\t{\n\t\tthis.profile = profile;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void setLocation(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic Identity getIdentity()\n\t{\n\t\treturn identity;\n\t}\n\n\tpublic void setIdentity(Identity identity)\n\t{\n\t\tthis.identity = identity;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Location.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport jakarta.xml.bind.annotation.XmlAttribute;\nimport jakarta.xml.bind.annotation.XmlType;\nimport jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;\n\n@XmlType(name = \"localLocation\")\nclass Location\n{\n\tprivate LocationIdentifier locationIdentifier;\n\tprivate byte[] privateKey;\n\tprivate byte[] publicKey;\n\tprivate byte[] x509Certificate;\n\tprivate int localPort;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic Location()\n\t{\n\t\t// Default constructor\n\t}\n\n\tpublic Location(LocationIdentifier locationIdentifier, byte[] privateKey, byte[] publicKey, byte[] x509Certificate, int localPort)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t\tthis.privateKey = privateKey;\n\t\tthis.publicKey = publicKey;\n\t\tthis.x509Certificate = x509Certificate;\n\t\tthis.localPort = localPort;\n\t}\n\n\t@XmlAttribute(name = \"locationId\")\n\t@XmlJavaTypeAdapter(LocationIdentifierXmlAdapter.class)\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic void setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPrivateKey()\n\t{\n\t\treturn privateKey;\n\t}\n\n\tpublic void setPrivateKey(byte[] privateKey)\n\t{\n\t\tthis.privateKey = privateKey;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPublicKey()\n\t{\n\t\treturn publicKey;\n\t}\n\n\tpublic void setPublicKey(byte[] publicKey)\n\t{\n\t\tthis.publicKey = publicKey;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getX509Certificate()\n\t{\n\t\treturn x509Certificate;\n\t}\n\n\tpublic void setX509Certificate(byte[] x509Certificate)\n\t{\n\t\tthis.x509Certificate = x509Certificate;\n\t}\n\n\t@XmlAttribute\n\tpublic int getLocalPort()\n\t{\n\t\treturn localPort;\n\t}\n\n\tpublic void setLocalPort(int localPort)\n\t{\n\t\tthis.localPort = localPort;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/LocationIdentifierXmlAdapter.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport jakarta.xml.bind.annotation.adapters.XmlAdapter;\n\npublic class LocationIdentifierXmlAdapter extends XmlAdapter<String, LocationIdentifier>\n{\n\t@Override\n\tpublic LocationIdentifier unmarshal(String v)\n\t{\n\t\treturn LocationIdentifier.fromString(v);\n\t}\n\n\t@Override\n\tpublic String marshal(LocationIdentifier v)\n\t{\n\t\treturn v.toString();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/PgpId.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport jakarta.xml.bind.annotation.XmlElement;\n\nimport java.util.List;\n\nclass PgpId\n{\n\tprivate List<SslId> sslIDs;\n\n\tpublic PgpId()\n\t{\n\t\t// Default constructor\n\t}\n\n\t@XmlElement(name = \"sslID\")\n\tpublic List<SslId> getSslIDs()\n\t{\n\t\treturn sslIDs;\n\t}\n\n\tpublic void setSslIDs(List<SslId> sslIDs)\n\t{\n\t\tthis.sslIDs = sslIDs;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Profile.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport jakarta.xml.bind.annotation.XmlAttribute;\nimport jakarta.xml.bind.annotation.XmlType;\n\n@XmlType(name = \"localProfile\")\nclass Profile\n{\n\tprivate byte[] pgpPrivateKey;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic Profile()\n\t{\n\t\t// Default constructor\n\t}\n\n\tpublic Profile(byte[] pgpPrivateKey)\n\t{\n\t\tthis.pgpPrivateKey = pgpPrivateKey;\n\t}\n\n\t@XmlAttribute\n\tpublic byte[] getPgpPrivateKey()\n\t{\n\t\treturn pgpPrivateKey;\n\t}\n\n\tpublic void setPgpPrivateKey(byte[] pgpPrivateKey)\n\t{\n\t\tthis.pgpPrivateKey = pgpPrivateKey;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/RSIdXmlAdapter.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport io.xeres.app.crypto.rsid.RSId;\nimport io.xeres.common.rsid.Type;\nimport jakarta.xml.bind.annotation.adapters.XmlAdapter;\n\npublic class RSIdXmlAdapter extends XmlAdapter<String, RSId>\n{\n\t@Override\n\tpublic RSId unmarshal(String v)\n\t{\n\t\treturn RSId.parse(v, Type.CERTIFICATE).orElseThrow(() -> new IllegalArgumentException(\"Couldn't parse certificate\"));\n\t}\n\n\t@Override\n\tpublic String marshal(RSId v)\n\t{\n\t\treturn v.getArmored();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/Root.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport jakarta.xml.bind.annotation.XmlElement;\nimport jakarta.xml.bind.annotation.XmlElementWrapper;\nimport jakarta.xml.bind.annotation.XmlRootElement;\n\nimport java.util.List;\n\n@XmlRootElement\nclass Root\n{\n\tprivate List<PgpId> pgpIDs;\n\tprivate List<Group> groups;\n\n\tpublic Root()\n\t{\n\t\t// Default constructor\n\t}\n\n\t@XmlElementWrapper\n\t@XmlElement(name = \"pgpID\")\n\tpublic List<PgpId> getPgpIDs()\n\t{\n\t\treturn pgpIDs;\n\t}\n\n\tpublic void setPgpIDs(List<PgpId> pgpIDs)\n\t{\n\t\tthis.pgpIDs = pgpIDs;\n\t}\n\n\t@XmlElementWrapper\n\t@XmlElement(name = \"group\")\n\tpublic List<Group> getGroups()\n\t{\n\t\treturn groups;\n\t}\n\n\tpublic void setGroups(List<Group> groups)\n\t{\n\t\tthis.groups = groups;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/backup/SslId.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.backup;\n\nimport jakarta.xml.bind.annotation.XmlAttribute;\n\nclass SslId\n{\n\tprivate String certificate;\n\n\tpublic SslId()\n\t{\n\t\t// Default constructor\n\t}\n\n\t@XmlAttribute(name = \"certificate\")\n\tpublic String getCertificate()\n\t{\n\t\treturn certificate;\n\t}\n\n\tpublic void setCertificate(String certificate)\n\t{\n\t\tthis.certificate = certificate;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/file/FileService.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.file;\n\nimport io.xeres.app.configuration.DataDirConfiguration;\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.app.database.model.file.FileDownload;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.share.Share;\nimport io.xeres.app.database.repository.FileDownloadRepository;\nimport io.xeres.app.database.repository.FileRepository;\nimport io.xeres.app.database.repository.ShareRepository;\nimport io.xeres.app.service.notification.file.FileNotificationService;\nimport io.xeres.app.util.expression.Expression;\nimport io.xeres.common.id.Sha1Sum;\nimport jakarta.persistence.EntityManager;\nimport jakarta.persistence.criteria.Predicate;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.data.domain.Sort;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.io.FileInputStream;\nimport java.io.IOException;\nimport java.nio.channels.FileChannel;\nimport java.nio.file.FileVisitResult;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.nio.file.attribute.BasicFileAttributes;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.TemporalAmount;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.stream.Collectors;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\n@Service\npublic class FileService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileService.class);\n\n\tpublic static final String DOWNLOAD_PREFIX = \".\";\n\tpublic static final String DOWNLOAD_EXTENSION = \".xrsdownload\";\n\n\tprivate static final TemporalAmount SCAN_DELAY = Duration.ofMinutes(10); // Delay between shares scan\n\n\tprivate static final Map<Sha1Sum, Path> temporaryHashes = new ConcurrentHashMap<>();\n\n\tstatic final int SMALL_FILE_SIZE = 1024 * 16; // 16 KB\n\n\tprivate final FileNotificationService fileNotificationService;\n\n\tprivate final ShareRepository shareRepository;\n\n\tprivate final FileRepository fileRepository;\n\n\tprivate final FileDownloadRepository fileDownloadRepository;\n\n\tprivate final HashBloomFilter bloomFilter;\n\n\tprivate final EntityManager entityManager;\n\n\tprivate static final String[] ignoredSuffixes = {\n\t\t\t\".bak\",\n\t\t\t\".sys\",\n\t\t\t\".com\",\n\t\t\t\".class\",\n\t\t\t\".obj\",\n\t\t\t\".o\",\n\t\t\t\".tmp\",\n\t\t\t\".temp\",\n\t\t\t\".cache\",\n\t\t\tDOWNLOAD_EXTENSION,\n\t\t\t\"~\"\n\t};\n\n\tprivate static final String[] ignoredPrefixes = {\n\t\t\t\"thumbs\",\n\t\t\t\"temp.\"\n\t};\n\n\tpublic FileService(FileNotificationService fileNotificationService, ShareRepository shareRepository, FileRepository fileRepository, FileDownloadRepository fileDownloadRepository, DataDirConfiguration dataDirConfiguration, EntityManager entityManager)\n\t{\n\t\tthis.fileNotificationService = fileNotificationService;\n\t\tthis.shareRepository = shareRepository;\n\t\tthis.fileRepository = fileRepository;\n\t\tthis.fileDownloadRepository = fileDownloadRepository;\n\t\tbloomFilter = new HashBloomFilter(dataDirConfiguration.getDataDir(), 10_000, 0.01d); // XXX: parameters will need experimenting, especially the max files (yes it can be extended, but not reduced)\n\t\tthis.entityManager = entityManager;\n\t\tupdateBloomFilter();\n\t}\n\n\t/**\n\t * Adds a share.\n\t *\n\t * @param share the share, the name must be unique otherwise nothing is added\n\t */\n\t@Transactional\n\tpublic void addShare(Share share)\n\t{\n\t\tif (shareRepository.findByName(share.getName()).isPresent())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsaveFullPath(share.getFile());\n\t\tshareRepository.save(share);\n\t}\n\n\t/**\n\t * This is used for migration only.\n\t */\n\t@Transactional\n\tpublic void encryptAllHashes()\n\t{\n\t\tfileRepository.findAll().forEach(file -> {\n\t\t\tif (file.getHash() != null)\n\t\t\t{\n\t\t\t\tfile.setEncryptedHash(encryptHash(file.getHash()));\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Checks shares and scans the oldest one.\n\t * <p>\n\t * Note that the user might expect at most each {@link #SCAN_DELAY} for a new file to be picked up, that's why\n\t * the time spent while scanning is included.\n\t */\n\t@Transactional\n\tpublic void checkForSharesToScan()\n\t{\n\t\tvar sharesToScan = shareRepository.findAll(Sort.by(Sort.Order.by(\"lastScanned\")).ascending());\n\n\t\tlog.debug(\"Shares to scan: {}\", sharesToScan);\n\t\tvar now = Instant.now();\n\t\tsharesToScan.stream()\n\t\t\t\t.filter(share -> share.getLastScanned() == null || share.getLastScanned().isBefore(now.minus(SCAN_DELAY)))\n\t\t\t\t.findFirst().ifPresent(share -> {\n\t\t\t\t\tlog.debug(\"Scanning: {}\", share);\n\t\t\t\t\tshare.setLastScanned(now);\n\t\t\t\t\tshareRepository.save(share);\n\t\t\t\t\tscanShare(share);\n\t\t\t\t});\n\t}\n\n\t/**\n\t * Synchronizes the list of shares.\n\t *\n\t * @param shares the list of shares to synchronize the database to.\n\t */\n\t@Transactional\n\tpublic void synchronize(List<Share> shares)\n\t{\n\t\temptyIfNull(shares).forEach(share -> {\n\t\t\tsaveFullPath(share.getFile());\n\t\t\tsetLastUpdated(share);\n\t\t\tshareRepository.save(share);\n\t\t});\n\n\t\tvar ids = shares.stream()\n\t\t\t\t.map(Share::getId)\n\t\t\t\t.filter(id -> id != 0)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\temptyIfNull(getShares()).forEach(share -> {\n\t\t\tif (!ids.contains(share.getId()))\n\t\t\t{\n\t\t\t\t// XXX: make sure no indexing process is handling this, it will have to be aborted first then. we need to store it in a list\n\t\t\t\tvar sharedDirectory = share.getFile();\n\t\t\t\tshareRepository.delete(share);\n\t\t\t\tfileRepository.delete(sharedDirectory);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Sets the last updated field properly. Try to keep the old one if possible and it definitely\n\t * must not be null.\n\t *\n\t * @param share the share to set the last updated field\n\t */\n\tprivate void setLastUpdated(Share share)\n\t{\n\t\tif (share.getId() != 0L)\n\t\t{\n\t\t\tvar oldShare = shareRepository.findById(share.getId()).orElseThrow(() -> new IllegalStateException(\"Share ID not found. Concurrent modification?\"));\n\t\t\tshare.setLastScanned(oldShare.getLastScanned());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tshare.setLastScanned(Instant.EPOCH);\n\t\t}\n\t}\n\n\t/**\n\t * Gets the shares.\n\t *\n\t * @return the list of shares\n\t */\n\tpublic List<Share> getShares()\n\t{\n\t\treturn shareRepository.findAll();\n\t}\n\n\t/**\n\t * Gets a map that allows to find the path of a share.\n\t *\n\t * @param shares the list of shares\n\t * @return a map that can be used to find the path of the list of shares\n\t */\n\tpublic Map<Long, String> getFilesMapFromShares(List<Share> shares)\n\t{\n\t\treturn shares.stream()\n\t\t\t\t.collect(Collectors.toMap(Share::getId, share -> toPath(getFullPath(share.getFile()))));\n\t}\n\n\tprivate static String toPath(List<File> files)\n\t{\n\t\treturn files.stream()\n\t\t\t\t.map(file -> file.getName().endsWith(\":\\\\\") ? file.getName().substring(0, file.getName().length() - 1) : file.getName()) // On Windows, C:\\ -> C: to avoid double file separators\n\t\t\t\t.collect(Collectors.joining(java.io.File.separator));\n\t}\n\n\tpublic Optional<File> findFileByHash(Sha1Sum hash)\n\t{\n\t\tvar files = fileRepository.findByHash(hash);\n\t\tif (files.isEmpty())\n\t\t{\n\t\t\treturn Optional.empty();\n\t\t}\n\t\treturn Optional.of(files.getFirst());\n\t}\n\n\tpublic Optional<File> findFileByEncryptedHash(Sha1Sum encryptedHash)\n\t{\n\t\tif (bloomFilter.mightContain(encryptedHash))\n\t\t{\n\t\t\tvar files = fileRepository.findByEncryptedHash(encryptedHash);\n\t\t\tif (!files.isEmpty())\n\t\t\t{\n\t\t\t\treturn Optional.of(files.getFirst());\n\t\t\t}\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tpublic Optional<Path> findFilePathByHash(Sha1Sum hash)\n\t{\n\t\tObjects.requireNonNull(hash);\n\t\tvar tempPath = temporaryHashes.get(hash);\n\t\tif (tempPath != null)\n\t\t{\n\t\t\treturn Optional.of(tempPath);\n\t\t}\n\t\treturn findFileByHash(hash).map(this::getFilePath);\n\t}\n\n\t/**\n\t * Deletes a file and its parents (if they're not the parent of other files, and they're not a share).\n\t *\n\t * @param file the file to delete\n\t */\n\tpublic void deleteFile(File file)\n\t{\n\t\tvar parents = getFullPath(file);\n\t\tfor (int i = parents.size() - 2; i >= 0; i--) // File is included in the path so -2 and we go up\n\t\t{\n\t\t\tvar parent = parents.get(i);\n\t\t\tif (fileRepository.countByParent(parent) != 1)\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (shareRepository.findShareByFile(parent).isPresent())\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tfile = parent;\n\t\t}\n\t\tfileRepository.delete(file);\n\t}\n\n\tpublic List<File> searchFiles(String name)\n\t{\n\t\treturn fileRepository.findAllByNameContainingIgnoreCase(name);\n\t}\n\n\tpublic List<File> searchFiles(List<Expression> expressions)\n\t{\n\t\tvar cb = entityManager.getCriteriaBuilder();\n\t\tvar query = cb.createQuery(File.class);\n\n\t\tvar file = query.from(File.class);\n\n\t\tList<Predicate> predicates = new ArrayList<>();\n\t\tfor (Expression expression : expressions)\n\t\t{\n\t\t\tpredicates.add(expression.toPredicate(cb, file));\n\t\t}\n\t\tquery.select(file).where(cb.and(predicates.toArray(new Predicate[0])));\n\t\treturn entityManager.createQuery(query).getResultList();\n\t}\n\n\tpublic Optional<Share> findShareForFile(File file)\n\t{\n\t\tSet<Long> fileIds = new HashSet<>();\n\t\twhile (file.hasParent())\n\t\t{\n\t\t\tfileIds.add(file.getId());\n\t\t\tfile = file.getParent();\n\t\t}\n\t\treturn shareRepository.findShareByFileIdIn(fileIds);\n\t}\n\n\tpublic long addDownload(String name, Sha1Sum hash, long size, Location location)\n\t{\n\t\tvar download = fileDownloadRepository.findByHash(hash);\n\t\tif (download.isPresent())\n\t\t{\n\t\t\treturn download.get().getId();\n\t\t}\n\n\t\tvar fileDownload = new FileDownload();\n\t\tfileDownload.setName(name);\n\t\tfileDownload.setHash(hash);\n\t\tfileDownload.setSize(size);\n\t\tfileDownload.setLocation(location);\n\t\tvar saved = fileDownloadRepository.save(fileDownload);\n\t\treturn saved.getId();\n\t}\n\n\t@Transactional\n\tpublic void suspendDownload(Sha1Sum hash, BitSet chunkMap)\n\t{\n\t\tfileDownloadRepository.findByHash(hash).ifPresent(fileDownload -> fileDownload.setChunkMap(chunkMap));\n\t}\n\n\t@Transactional\n\tpublic void markDownloadAsCompleted(Sha1Sum hash)\n\t{\n\t\tfileDownloadRepository.findByHash(hash).ifPresent(fileDownload -> fileDownload.setCompleted(true));\n\t}\n\n\tpublic Optional<FileDownload> findById(long id)\n\t{\n\t\treturn fileDownloadRepository.findById(id);\n\t}\n\n\t@Transactional\n\tpublic void removeDownload(long id)\n\t{\n\t\tfileDownloadRepository.deleteById(id);\n\t}\n\n\tpublic Optional<Sha1Sum> findByPath(Path path)\n\t{\n\t\tvar candidates = fileRepository.findAllByName(path.getFileName().toString());\n\t\tfor (File candidate : candidates)\n\t\t{\n\t\t\tif (getFullPathAsString(candidate).equals(path.toString()))\n\t\t\t{\n\t\t\t\treturn Optional.of(candidate.getHash());\n\t\t\t}\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tpublic static Sha1Sum encryptHash(Sha1Sum hash)\n\t{\n\t\tvar digest = new Sha1MessageDigest();\n\t\tdigest.update(hash.getBytes());\n\t\treturn digest.getSum();\n\t}\n\n\tprivate void saveFullPath(File file)\n\t{\n\t\tvar tree = getFullPath(file);\n\t\t// Only save the new file paths\n\t\ttree.forEach(f -> {\n\t\t\tif (f.getId() == 0L)\n\t\t\t{\n\t\t\t\tvar saved = fileRepository.save(f);\n\t\t\t\tf.setId(saved.getId());\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate List<File> getFullPath(File file)\n\t{\n\t\tList<File> tree = new ArrayList<>();\n\n\t\ttree.add(file);\n\t\twhile (file.getParent() != null)\n\t\t{\n\t\t\tvar parent = file.getParent();\n\t\t\ttree.add(parent);\n\t\t\tfile = parent;\n\t\t}\n\t\tCollections.reverse(tree);\n\n\t\t// We need to use findByNameAndParent*Name*() here because the parents are built on the fly and not taken from the database.\n\t\t// Otherwise, hibernate would complain about unsaved transient references.\n\t\ttree.forEach(fileToUpdate -> fileRepository.findByNameAndParentName(fileToUpdate.getName(), fileToUpdate.getParent() != null ? fileToUpdate.getParent().getName() : null).ifPresent(fileFound -> fileToUpdate.setId(fileFound.getId())));\n\t\treturn tree;\n\t}\n\n\tprivate String getFullPathAsString(File file)\n\t{\n\t\treturn toPath(getFullPath(file));\n\t}\n\n\tvoid scanShare(Share share)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar ioBuffer = new byte[SMALL_FILE_SIZE];\n\t\t\tfileNotificationService.startScanning(share);\n\t\t\tvar directory = share.getFile();\n\t\t\tvar directoryPath = getFilePath(directory);\n\t\t\tvar visitor = new TrackingFileVisitor(fileRepository, directory)\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tpublic FileVisitResult visitFile(Path file, BasicFileAttributes attrs)\n\t\t\t\t{\n\t\t\t\t\tObjects.requireNonNull(file);\n\t\t\t\t\tObjects.requireNonNull(attrs);\n\t\t\t\t\tif (isIndexableFile(file, attrs))\n\t\t\t\t\t{\n\t\t\t\t\t\tindexFile(file, attrs);\n\t\t\t\t\t}\n\t\t\t\t\treturn FileVisitResult.CONTINUE;\n\t\t\t\t}\n\n\t\t\t\t@Override\n\t\t\t\tpublic FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)\n\t\t\t\t{\n\t\t\t\t\tObjects.requireNonNull(dir);\n\t\t\t\t\tObjects.requireNonNull(attrs);\n\t\t\t\t\tif (isIndexableDirectory(dir, attrs))\n\t\t\t\t\t{\n\t\t\t\t\t\tindexDirectory(dir, attrs);\n\t\t\t\t\t\treturn FileVisitResult.CONTINUE;\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\treturn FileVisitResult.SKIP_SUBTREE;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t@Override\n\t\t\t\tpublic FileVisitResult postVisitDirectory(Path dir, IOException exc)\n\t\t\t\t{\n\t\t\t\t\tObjects.requireNonNull(dir);\n\t\t\t\t\tsuper.postVisitDirectory(dir, exc);\n\t\t\t\t\tif (exc != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tlog.debug(\"Failed to fully scan directory {}: {}\", dir, exc.getMessage());\n\t\t\t\t\t}\n\t\t\t\t\treturn FileVisitResult.CONTINUE;\n\t\t\t\t}\n\n\t\t\t\t@Override\n\t\t\t\tpublic FileVisitResult visitFileFailed(Path file, IOException exc)\n\t\t\t\t{\n\t\t\t\t\tObjects.requireNonNull(file);\n\t\t\t\t\tlog.debug(\"Visiting file {} failed: {}\", file, exc.getMessage());\n\t\t\t\t\treturn FileVisitResult.CONTINUE;\n\t\t\t\t}\n\n\t\t\t\tprivate void indexFile(Path file, BasicFileAttributes attrs)\n\t\t\t\t{\n\t\t\t\t\tvar currentFile = fileRepository.findByNameAndParent(file.getFileName().toString(), getCurrentDirectory()).orElseGet(() -> File.createFile(getCurrentDirectory(), file.getFileName().toString(), attrs.size(), null));\n\t\t\t\t\tvar lastModified = attrs.lastModifiedTime().toInstant();\n\t\t\t\t\tlog.debug(\"Checking file {}, modification time: {}\", file, lastModified);\n\t\t\t\t\tif (currentFile.getModified() == null || lastModified.isAfter(currentFile.getModified()))\n\t\t\t\t\t{\n\t\t\t\t\t\tlog.debug(\"Current file in database, modified: {}\", currentFile.getModified());\n\t\t\t\t\t\tvar hash = calculateFileHash(file, ioBuffer);\n\t\t\t\t\t\tcurrentFile.setHash(hash);\n\t\t\t\t\t\tcurrentFile.setEncryptedHash(encryptHash(hash));\n\t\t\t\t\t\tcurrentFile.setModified(lastModified);\n\t\t\t\t\t\tfileRepository.save(currentFile);\n\t\t\t\t\t\tsetChanged();\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tprivate void indexDirectory(Path dir, BasicFileAttributes attrs)\n\t\t\t\t{\n\t\t\t\t\tsuper.preVisitDirectory(dir, attrs);\n\t\t\t\t\tlog.debug(\"Entering directory {}\", dir);\n\t\t\t\t\tvar directory = getCurrentDirectory();\n\t\t\t\t\tif (fileRepository.findByNameAndParent(directory.getName(), directory.getParent()).isEmpty())\n\t\t\t\t\t{\n\t\t\t\t\t\tfileRepository.save(directory);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t};\n\t\t\tFiles.walkFileTree(directoryPath, visitor);\n\t\t\tdirectory.setModified(Files.getLastModifiedTime(directoryPath).toInstant());\n\t\t\tfileRepository.save(directory);\n\n\t\t\tif (visitor.foundChanges())\n\t\t\t{\n\t\t\t\tupdateBloomFilter();\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tfileNotificationService.stopScanning();\n\t\t}\n\t}\n\n\tpublic Path getFilePath(File file)\n\t{\n\t\tif (file.hasParent())\n\t\t{\n\t\t\treturn getFilePath(file.getParent()).resolve(file.getName());\n\t\t}\n\t\treturn Path.of(file.getName());\n\t}\n\n\tprivate boolean isIndexableFile(Path file, BasicFileAttributes attrs)\n\t{\n\t\tif (attrs.isRegularFile() && attrs.size() > 0)\n\t\t{\n\t\t\tvar fileName = file.getFileName().toString();\n\t\t\treturn !isIgnoredFile(fileName);\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate boolean isIndexableDirectory(Path directory, BasicFileAttributes attrs)\n\t{\n\t\tif (attrs.isDirectory())\n\t\t{\n\t\t\tvar directoryName = directory.getFileName().toString();\n\t\t\treturn !isIgnoredDirectory(directoryName);\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate static boolean isIgnoredFile(String fileName)\n\t{\n\t\tfileName = fileName.toLowerCase(Locale.ROOT);\n\n\t\tfor (var ignoredSuffix : ignoredSuffixes)\n\t\t{\n\t\t\tif (fileName.endsWith(ignoredSuffix))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\tfor (var ignoredPrefix : ignoredPrefixes)\n\t\t{\n\t\t\tif (fileName.startsWith(ignoredPrefix))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate static boolean isIgnoredDirectory(String dirName)\n\t{\n\t\treturn dirName.startsWith(\".\");\n\t}\n\n\tpublic Sha1Sum calculateTemporaryFileHash(Path path)\n\t{\n\t\tvar byPath = findByPath(path);\n\t\tif (byPath.isPresent())\n\t\t{\n\t\t\treturn byPath.get();\n\t\t}\n\t\tvar ioBuffer = new byte[SMALL_FILE_SIZE];\n\t\tvar hash = calculateFileHash(path, ioBuffer);\n\t\tif (hash != null)\n\t\t{\n\t\t\ttemporaryHashes.put(hash, path);\n\t\t}\n\t\treturn hash;\n\t}\n\n\tSha1Sum calculateFileHash(Path path, byte[] ioBuffer)\n\t{\n\t\tlog.debug(\"Calculating file hash of file {}\", path);\n\t\ttry\n\t\t{\n\t\t\tvar size = Files.size(path);\n\n\t\t\tif (size == 0)\n\t\t\t{\n\t\t\t\tlog.debug(\"File is empty, ignoring\");\n\t\t\t\treturn null; // We ignore empty files\n\t\t\t}\n\t\t\telse if (size > SMALL_FILE_SIZE)\n\t\t\t{\n\t\t\t\treturn calculateLargeFileHash(path);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn calculateSmallFileHash(path, ioBuffer);\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.warn(\"Error while trying to compute hash of file {}\", path, e);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate Sha1Sum calculateLargeFileHash(Path path) throws IOException\n\t{\n\t\ttry (var fc = FileChannel.open(path, StandardOpenOption.READ)) // ExtendedOpenOption.DIRECT is useless for memory mapped files\n\t\t{\n\t\t\tfileNotificationService.startScanningFile(path);\n\t\t\tvar md = new Sha1MessageDigest();\n\n\t\t\tvar size = fc.size();\n\t\t\tvar offset = 0L;\n\n\t\t\twhile (size > 0)\n\t\t\t{\n\t\t\t\tvar bufferSize = Math.min(size, Integer.MAX_VALUE);\n\t\t\t\tvar buffer = fc.map(FileChannel.MapMode.READ_ONLY, offset, bufferSize);\n\n\t\t\t\tmd.update(buffer);\n\t\t\t\toffset += bufferSize;\n\t\t\t\tsize -= bufferSize;\n\t\t\t}\n\t\t\treturn md.getSum();\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tfileNotificationService.stopScanningFile();\n\t\t}\n\t}\n\n\tprivate Sha1Sum calculateSmallFileHash(Path path, byte[] ioBuffer) throws IOException\n\t{\n\t\ttry (var ios = new FileInputStream(path.toFile()))\n\t\t{\n\t\t\tfileNotificationService.startScanningFile(path);\n\t\t\tvar md = new Sha1MessageDigest();\n\t\t\tint read;\n\n\t\t\twhile ((read = ios.read(ioBuffer)) > 0)\n\t\t\t{\n\t\t\t\tmd.update(ioBuffer, 0, read);\n\t\t\t}\n\t\t\treturn md.getSum();\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tfileNotificationService.stopScanningFile();\n\t\t}\n\t}\n\n\tprivate void updateBloomFilter()\n\t{\n\t\t// XXX: extend the bloom filter if needed\n\t\tbloomFilter.clear();\n\t\tfileRepository.findAll().forEach(file -> bloomFilter.add(file.getEncryptedHash()));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/file/HashBloomFilter.java",
    "content": "/*\n * Copyright (c) 2023-2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.file;\n\nimport com.sangupta.bloomfilter.AbstractBloomFilter;\nimport com.sangupta.bloomfilter.core.BitArray;\nimport com.sangupta.bloomfilter.core.JavaBitSetArray;\nimport com.sangupta.bloomfilter.core.MMapFileBackedBitArray;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.Collection;\n\n/**\n * A Bloom filter implementation specifically designed for storing Turtle file hashes.\n * <p>\n * Use add() to insert entries and mightContain() to check if an entry might be in it. False positives\n * are possible and one just has to make sure that the probability is low enough so that accesses to the\n * database are kept at a minimum when not needed. In any case a match needs a database access for confirmation.\n * <p>\n * Removing an entry is not possible. One has to clear and re-add all entries.\n * <p>\n * The entries are persisted to disk.\n */\npublic class HashBloomFilter\n{\n\tprivate static final String PERSISTENT_FILE = \"turtle_bf\";\n\tprivate final AbstractBloomFilter<Sha1Sum> bFilter;\n\tprivate BitArray bArray;\n\n\tpublic HashBloomFilter(String baseDir, int expectedInsertions, double falsePositiveProbability)\n\t{\n\t\tbFilter = new AbstractBloomFilter<>(expectedInsertions, falsePositiveProbability, (sha1Sum, byteSink) -> byteSink.putBytes(sha1Sum.getBytes()))\n\t\t{\n\t\t\t@Override\n\t\t\tprotected BitArray createBitArray(int numBits)\n\t\t\t{\n\t\t\t\tif (baseDir == null)\n\t\t\t\t{\n\t\t\t\t\tbArray = new JavaBitSetArray(numBits);\n\t\t\t\t\treturn bArray;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\ttry\n\t\t\t\t\t{\n\t\t\t\t\t\tbArray = new MMapFileBackedBitArray(Path.of(baseDir, PERSISTENT_FILE).toFile(), numBits);\n\t\t\t\t\t\treturn bArray;\n\t\t\t\t\t}\n\t\t\t\t\tcatch (IOException e)\n\t\t\t\t\t{\n\t\t\t\t\t\tthrow new RuntimeException(e);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic boolean contains(Sha1Sum value)\n\t\t\t{\n\t\t\t\t// The following workaround (the getBytes() call) is needed unless\n\t\t\t\t// https://github.com/sangupta/bloomfilter/pull/5 is merged and a new upstream release is done.\n\t\t\t\t// We also need to clone it otherwise the array gets modified.\n\t\t\t\treturn super.contains(value.clone().getBytes());\n\t\t\t}\n\t\t};\n\t}\n\n\t/**\n\t * Adds a value.\n\t *\n\t * @param value the value to be added\n\t */\n\tpublic void add(Sha1Sum value)\n\t{\n\t\tbFilter.add(value);\n\t}\n\n\t/**\n\t * Adds all the values from the given collection.\n\t *\n\t * @param values the collection of values to be added\n\t */\n\tpublic void addAll(Collection<Sha1Sum> values)\n\t{\n\t\tbFilter.addAll(values);\n\t}\n\n\t/**\n\t * Determines if the given value might be in the bloom filter.\n\t *\n\t * @param value the value to check\n\t * @return true if the value is possibly in it, false if it's definitely not\n\t */\n\tpublic boolean mightContain(Sha1Sum value)\n\t{\n\t\treturn bFilter.contains(value);\n\t}\n\n\t/**\n\t * Determines if all the given values might be contained in the bloom filter.\n\t *\n\t * @param values the collection of values to check\n\t * @return true if all the values are possibly in it, false if at least one is definitely not\n\t */\n\tpublic boolean mightContainAll(Collection<Sha1Sum> values)\n\t{\n\t\treturn bFilter.containsAll(values);\n\t}\n\n\t/**\n\t * Clears the Bloom filter back to an empty state.\n\t */\n\tpublic void clear()\n\t{\n\t\tbArray.clear();\n\t}\n}"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/file/TrackingFileVisitor.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.file;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.app.database.repository.FileRepository;\n\nimport java.io.IOException;\nimport java.nio.file.FileVisitResult;\nimport java.nio.file.FileVisitor;\nimport java.nio.file.Path;\nimport java.nio.file.attribute.BasicFileAttributes;\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class TrackingFileVisitor implements FileVisitor<Path>\n{\n\tprivate final FileRepository fileRepository;\n\tprivate boolean skipRoot; // The first entered directory is already the root directory\n\tprivate final List<File> directories = new ArrayList<>();\n\tprivate boolean foundChanges;\n\n\tpublic TrackingFileVisitor(FileRepository fileRepository, File rootDirectory)\n\t{\n\t\tthis.fileRepository = fileRepository;\n\t\tdirectories.addLast(rootDirectory);\n\t}\n\n\t@Override\n\tpublic FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)\n\t{\n\t\tif (!skipRoot)\n\t\t{\n\t\t\tskipRoot = true;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar directory = fileRepository.findByNameAndParent(dir.getFileName().toString(), directories.getLast()).orElseGet(() -> File.createDirectory(directories.getLast(), dir.getFileName().toString(), attrs.lastModifiedTime().toInstant()));\n\t\t\tdirectories.addLast(directory);\n\t\t}\n\t\treturn FileVisitResult.CONTINUE;\n\t}\n\n\t@Override\n\tpublic FileVisitResult visitFile(Path file, BasicFileAttributes attrs)\n\t{\n\t\treturn FileVisitResult.CONTINUE;\n\t}\n\n\t@Override\n\tpublic FileVisitResult visitFileFailed(Path file, IOException exc)\n\t{\n\t\treturn FileVisitResult.CONTINUE;\n\t}\n\n\t@Override\n\tpublic FileVisitResult postVisitDirectory(Path dir, IOException exc)\n\t{\n\t\tdirectories.removeLast();\n\t\treturn FileVisitResult.CONTINUE;\n\t}\n\n\tpublic File getCurrentDirectory()\n\t{\n\t\treturn directories.getLast();\n\t}\n\n\tpublic boolean foundChanges()\n\t{\n\t\treturn foundChanges;\n\t}\n\n\tvoid setChanged()\n\t{\n\t\tfoundChanges = true;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/identicon/IdenticonService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.identicon;\n\nimport io.xeres.app.configuration.CacheDirConfiguration;\nimport io.xeres.common.gxs.GxsGroupConstants;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\n\nimport javax.imageio.ImageIO;\nimport java.awt.geom.AffineTransform;\nimport java.awt.image.AffineTransformOp;\nimport java.awt.image.BufferedImage;\nimport java.awt.image.WritableRaster;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\n@Service\npublic class IdenticonService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(IdenticonService.class);\n\n\tprivate final CacheDirConfiguration cacheDirConfiguration;\n\n\tpublic IdenticonService(CacheDirConfiguration cacheDirConfiguration)\n\t{\n\t\tthis.cacheDirConfiguration = cacheDirConfiguration;\n\t}\n\n\tpublic byte[] getIdenticon(byte[] hash)\n\t{\n\t\tvar data = getIdenticonFromCache(hash);\n\t\tif (data != null)\n\t\t{\n\t\t\treturn data;\n\t\t}\n\n\t\tvar image = generateIdenticon(hash, GxsGroupConstants.IMAGE_SIDE_SIZE, GxsGroupConstants.IMAGE_SIDE_SIZE);\n\n\t\tvar output = new ByteArrayOutputStream();\n\t\ttry\n\t\t{\n\t\t\tImageIO.write(image, \"png\", output);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(\"Could not generate identicon\", e);\n\t\t}\n\t\tvar outputData = output.toByteArray();\n\t\tputIdenticonToCache(hash, outputData);\n\t\treturn outputData;\n\t}\n\n\tprivate byte[] getIdenticonFromCache(byte[] hash)\n\t{\n\t\tvar path = getFilePath(hash);\n\t\tif (path == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tif (path.toFile().canRead())\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\treturn Files.readAllBytes(path);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.warn(\"Couldn't read cached file {}: {}\", path, e.getMessage());\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate void putIdenticonToCache(byte[] hash, byte[] data)\n\t{\n\t\tvar path = getFilePath(hash);\n\t\tif (path == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tFiles.write(path, data);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.warn(\"Couldn't write cached file {}: {}\", path, e.getMessage());\n\t\t}\n\t}\n\n\tprivate Path getFilePath(byte[] hash)\n\t{\n\t\tvar cacheDir = cacheDirConfiguration.getCacheDir();\n\t\tif (cacheDir == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn Path.of(cacheDir, String.format(\"identicon_%02x%02x%02x\", Byte.toUnsignedInt(hash[0]), Byte.toUnsignedInt(hash[1]), Byte.toUnsignedInt(hash[2])));\n\t}\n\n\t/**\n\t * Generates an identicon like the ones from GitHub.\n\t * <a href=\"https://github.com/davidhampgonsalves/Contact-Identicons\">Android version</a> by David Hamp-Gonsalves.\n\t * <a href=\"https://stackoverflow.com/questions/40697056/how-can-i-create-identicons-using-java-or-android\">Java version</a> by Kevin Grandjean.\n\t *\n\t * @param hash        the hash, at least 3 bytes are needed\n\t * @param imageWidth  the width of the images\n\t * @param imageHeight the height of the image\n\t * @return a buffered image\n\t */\n\tprivate BufferedImage generateIdenticon(byte[] hash, int imageWidth, int imageHeight)\n\t{\n\t\tassert hash != null && hash.length >= 3;\n\t\tvar width = 5;\n\t\tvar height = 5;\n\n\t\tvar identicon = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);\n\t\tWritableRaster raster = identicon.getRaster();\n\n\t\tvar background = new int[]{240, 240, 240, 255};\n\t\tvar foreground = new int[]{hash[0] & 255, hash[1] & 255, hash[2] & 255, 255};\n\n\t\tfor (var x = 0; x < width; x++)\n\t\t{\n\t\t\t//Enforce horizontal symmetry\n\t\t\tint i = x < 3 ? x : 4 - x;\n\t\t\tfor (var y = 0; y < height; y++)\n\t\t\t{\n\t\t\t\tint[] pixelColor;\n\t\t\t\t//toggle pixels based on bit being on/off\n\t\t\t\tif ((hash[i] >> y & 1) == 1)\n\t\t\t\t{\n\t\t\t\t\tpixelColor = foreground;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tpixelColor = background;\n\t\t\t\t}\n\t\t\t\traster.setPixel(x, y, pixelColor);\n\t\t\t}\n\t\t}\n\n\t\tvar finalImage = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB);\n\n\t\t//Scale image to the size you want\n\t\tvar at = new AffineTransform();\n\t\tat.scale((double) imageWidth / width, (double) imageHeight / height);\n\t\tvar op = new AffineTransformOp(at, AffineTransformOp.TYPE_NEAREST_NEIGHBOR);\n\t\tfinalImage = op.filter(identicon, finalImage);\n\n\t\treturn finalImage;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/NotificationService.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification;\n\nimport io.xeres.common.rest.notification.Notification;\nimport org.springframework.http.MediaType;\nimport org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;\nimport org.springframework.web.servlet.mvc.method.annotation.SseEmitter;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CopyOnWriteArrayList;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\npublic abstract class NotificationService\n{\n\tfinal List<SseEmitter> emitters = new CopyOnWriteArrayList<>();\n\n\tprivate Notification previousNotification;\n\tprivate final AtomicBoolean running = new AtomicBoolean();\n\n\tprotected NotificationService()\n\t{\n\t\trunning.lazySet(true);\n\t}\n\n\t/**\n\t * Sends that notification to all connecting clients. It's a kind of \"sync\" notification so that we\n\t * get immediate data available. Use it for notifications that report a \"state\".\n\t *\n\t * @return the initial notification to send\n\t */\n\tprotected Notification initialNotification()\n\t{\n\t\treturn null;\n\t}\n\n\tpublic SseEmitter addClient()\n\t{\n\t\tif (!running.get())\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar emitter = new SseEmitter(-1L); // no timeout\n\t\taddEmitter(emitter);\n\t\temitter.onCompletion(() -> removeEmitter(emitter));\n\t\temitter.onTimeout(() -> removeEmitter(emitter));\n\n\t\tCompletableFuture.delayedExecutor(1, TimeUnit.MILLISECONDS).execute(() -> sendInitialNotificationIfNeeded(emitter)); // send a notification to the client that just connected to \"sync\" it (XXX: remove? what happens if the event is sent immediately? test it... I don't like that delay stuff...)\n\n\t\treturn emitter;\n\t}\n\n\tpublic void sendNotification(Notification notification)\n\t{\n\t\tsendNotification(notification, null);\n\t}\n\n\t/**\n\t * Closes all the emitters. If not called, tomcat will complain about non-closed connections\n\t * on shutdown.\n\t */\n\tpublic void shutdown()\n\t{\n\t\trunning.set(false);\n\t\temitters.forEach(ResponseBodyEmitter::complete);\n\t}\n\n\tprivate void sendInitialNotificationIfNeeded(SseEmitter emitter)\n\t{\n\t\tvar notification = initialNotification();\n\t\tif (notification != null)\n\t\t{\n\t\t\tsendNotification(notification, emitter);\n\t\t}\n\t}\n\n\tprivate void sendNotification(Notification notification, SseEmitter specificEmitter)\n\t{\n\t\tObjects.requireNonNull(notification);\n\n\t\tif (!running.get())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (specificEmitter != null)\n\t\t{\n\t\t\tsendSseNotification(specificEmitter, notification);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (notification.ignoreDuplicates() && notification.equals(previousNotification))\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpreviousNotification = notification;\n\n\t\t\tsendSseNotification(notification);\n\t\t}\n\t}\n\n\tprivate void addEmitter(SseEmitter emitter)\n\t{\n\t\temitters.add(emitter);\n\t}\n\n\tprivate void removeEmitter(SseEmitter emitter)\n\t{\n\t\temitters.remove(emitter);\n\t}\n\n\tprivate void sendSseNotification(Notification notification)\n\t{\n\t\tList<SseEmitter> deadEmitters = new ArrayList<>();\n\n\t\temitters.forEach(emitter ->\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\temitter.send(createEventBuilder(notification));\n\t\t\t}\n\t\t\tcatch (IOException _)\n\t\t\t{\n\t\t\t\tdeadEmitters.add(emitter);\n\t\t\t}\n\t\t});\n\t\temitters.removeAll(deadEmitters);\n\t}\n\n\tprivate void sendSseNotification(SseEmitter emitter, Notification notification)\n\t{\n\t\ttry\n\t\t{\n\t\t\temitter.send(createEventBuilder(notification));\n\t\t}\n\t\tcatch (IOException _)\n\t\t{\n\t\t\temitters.remove(emitter);\n\t\t}\n\t}\n\n\tprivate static SseEmitter.SseEventBuilder createEventBuilder(Notification notification)\n\t{\n\t\tvar event = SseEmitter.event();\n\t\tevent.data(notification, MediaType.APPLICATION_JSON);\n\t\tevent.name(notification.getType());\n\t\treturn event;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/availability/AvailabilityNotificationService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.availability;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.notification.availability.AvailabilityChange;\nimport org.springframework.stereotype.Service;\n\n@Service\npublic class AvailabilityNotificationService extends NotificationService\n{\n\tpublic void changeAvailability(Location location, Availability availability)\n\t{\n\t\tsendNotification(new AvailabilityChange(availability, location.getProfile().getId(), location.getProfile().getName(), location.getId(), location.getSafeName()));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/board/BoardNotificationService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.board;\n\nimport io.xeres.app.service.BoardMessageService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.app.xrs.service.board.item.BoardGroupItem;\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.common.rest.notification.board.AddOrUpdateBoardGroups;\nimport io.xeres.common.rest.notification.board.AddOrUpdateBoardMessages;\nimport io.xeres.common.rest.notification.board.SetBoardGroupMessagesReadState;\nimport io.xeres.common.rest.notification.board.SetBoardMessageReadState;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\nimport static io.xeres.app.database.model.board.BoardMapper.toBoardMessageDTOs;\nimport static io.xeres.app.database.model.board.BoardMapper.toDTOs;\n\n@Service\npublic class BoardNotificationService extends NotificationService\n{\n\tprivate final UnHtmlService unHtmlService;\n\tprivate final BoardMessageService boardMessageService;\n\n\tpublic BoardNotificationService(UnHtmlService unHtmlService, BoardMessageService boardMessageService)\n\t{\n\t\tthis.unHtmlService = unHtmlService;\n\t\tthis.boardMessageService = boardMessageService;\n\t}\n\n\tpublic void addOrUpdateGroups(List<BoardGroupItem> groups)\n\t{\n\t\tsendNotification(new AddOrUpdateBoardGroups(toDTOs(groups)));\n\t}\n\n\tpublic void addOrUpdateMessages(List<BoardMessageItem> messages)\n\t{\n\t\tvar page = new PageImpl<>(messages);\n\t\tsendNotification(new AddOrUpdateBoardMessages(toBoardMessageDTOs(unHtmlService, page,\n\t\t\t\tboardMessageService.getAuthorsMapFromMessages(page),\n\t\t\t\tboardMessageService.getMessagesMapFromMessages(messages))));\n\t}\n\n\tpublic void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\tsendNotification(new SetBoardMessageReadState(groupId, messageId, read));\n\t}\n\n\tpublic void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tsendNotification(new SetBoardGroupMessagesReadState(groupId, read));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/channel/ChannelNotificationService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.channel;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.app.xrs.service.channel.ChannelRsService;\nimport io.xeres.app.xrs.service.channel.item.ChannelGroupItem;\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.rest.notification.channel.AddOrUpdateChannelGroups;\nimport io.xeres.common.rest.notification.channel.AddOrUpdateChannelMessages;\nimport io.xeres.common.rest.notification.channel.SetChannelGroupMessagesReadState;\nimport io.xeres.common.rest.notification.channel.SetChannelMessageReadState;\nimport org.apache.commons.collections4.SetUtils;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.database.model.channel.ChannelMapper.toChannelMessageDTOs;\nimport static io.xeres.app.database.model.channel.ChannelMapper.toDTOs;\n\n@Service\npublic class ChannelNotificationService extends NotificationService\n{\n\tprivate final ChannelRsService channelRsService;\n\tprivate final IdentityService identityService;\n\tprivate final UnHtmlService unHtmlService;\n\n\tpublic ChannelNotificationService(@Lazy ChannelRsService channelRsService, IdentityService identityService, UnHtmlService unHtmlService)\n\t{\n\t\tthis.channelRsService = channelRsService;\n\t\tthis.identityService = identityService;\n\t\tthis.unHtmlService = unHtmlService;\n\t}\n\n\tpublic void addOrUpdateGroups(List<ChannelGroupItem> groups)\n\t{\n\t\tsendNotification(new AddOrUpdateChannelGroups(toDTOs(groups)));\n\t}\n\n\tpublic void addOrUpdateMessages(List<ChannelMessageItem> messages)\n\t{\n\t\tsendNotification(new AddOrUpdateChannelMessages(toChannelMessageDTOs(unHtmlService, messages,\n\t\t\t\tgetAuthorsMapFromMessages(messages),\n\t\t\t\tgetMessagesMapFromMessages(messages),\n\t\t\t\tfalse)));\n\t}\n\n\tpublic void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\tsendNotification(new SetChannelMessageReadState(groupId, messageId, read));\n\t}\n\n\tpublic void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tsendNotification(new SetChannelGroupMessagesReadState(groupId, read));\n\t}\n\n\tprivate Map<GxsId, IdentityGroupItem> getAuthorsMapFromMessages(List<ChannelMessageItem> channelMessages)\n\t{\n\t\tvar authors = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getAuthorGxsId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn identityService.findAll(authors).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, Function.identity()));\n\t}\n\n\tprivate Map<MsgId, ChannelMessageItem> getMessagesMapFromMessages(List<ChannelMessageItem> channelMessages)\n\t{\n\t\tvar msgIds = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tvar parentMsgIds = channelMessages.stream()\n\t\t\t\t.map(ChannelMessageItem::getParentMsgId)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\treturn channelRsService.findAllMessages(SetUtils.union(msgIds, parentMsgIds)).stream()\n\t\t\t\t.collect(Collectors.toMap(ChannelMessageItem::getMsgId, Function.identity()));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/contact/ContactNotificationService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.contact;\n\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.service.ContactService;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.rest.notification.contact.AddOrUpdateContacts;\nimport io.xeres.common.rest.notification.contact.RemoveContacts;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n@Service\npublic class ContactNotificationService extends NotificationService\n{\n\tprivate final ContactService contactService;\n\n\tpublic ContactNotificationService(ContactService contactService)\n\t{\n\t\tthis.contactService = contactService;\n\t}\n\n\tpublic void addOrUpdateIdentities(List<IdentityGroupItem> identities)\n\t{\n\t\taddOrUpdateContacts(contactService.toContacts(identities));\n\t}\n\n\tpublic void removeIdentities(List<IdentityGroupItem> identities)\n\t{\n\t\tremoveContacts(contactService.toContacts(identities));\n\t}\n\n\tpublic void addOrUpdateProfile(Profile profile)\n\t{\n\t\taddOrUpdateContacts(List.of(contactService.toContact(profile)));\n\t}\n\n\tpublic void removeProfile(Profile profile)\n\t{\n\t\tremoveContacts(List.of(contactService.toContact(profile)));\n\t}\n\n\tprivate void addOrUpdateContacts(List<Contact> contacts)\n\t{\n\t\tsendNotification(new AddOrUpdateContacts(contacts));\n\t}\n\n\tprivate void removeContacts(List<Contact> contacts)\n\t{\n\t\tsendNotification(new RemoveContacts(contacts));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/file/FileNotificationService.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.file;\n\nimport io.xeres.app.database.model.share.Share;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.common.rest.notification.Notification;\nimport io.xeres.common.rest.notification.file.FileNotification;\nimport io.xeres.common.rest.notification.file.FileNotificationAction;\nimport org.springframework.stereotype.Service;\n\nimport java.nio.file.Path;\n\nimport static io.xeres.common.rest.notification.file.FileNotificationAction.*;\n\n@Service\npublic class FileNotificationService extends NotificationService\n{\n\tprivate FileNotificationAction action = NONE;\n\tprivate String shareName;\n\tprivate String scannedFile;\n\n\t@Override\n\tprotected Notification initialNotification()\n\t{\n\t\treturn createNotification();\n\t}\n\n\tprivate Notification createNotification()\n\t{\n\t\treturn new FileNotification(action, shareName, scannedFile);\n\t}\n\n\tpublic void startScanning(Share share)\n\t{\n\t\taction = START_SCANNING;\n\t\tshareName = share.getName();\n\t\tsendNotification(createNotification());\n\t}\n\n\tpublic void startScanningFile(Path scannedFile)\n\t{\n\t\taction = START_HASHING;\n\t\tthis.scannedFile = scannedFile.toString();\n\t\tsendNotification(createNotification());\n\t}\n\n\tpublic void stopScanningFile()\n\t{\n\t\taction = STOP_HASHING;\n\t\tscannedFile = null;\n\t\tsendNotification(createNotification());\n\t}\n\n\tpublic void stopScanning()\n\t{\n\t\taction = STOP_SCANNING;\n\t\tshareName = null;\n\t\tscannedFile = null;\n\t\tsendNotification(createNotification());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/file/FileSearchNotificationService.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.file;\n\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.rest.notification.file.FileSearchNotification;\nimport org.springframework.stereotype.Service;\n\n@Service\npublic class FileSearchNotificationService extends NotificationService\n{\n\tpublic void foundFile(int requestId, String name, long size, Sha1Sum hash)\n\t{\n\t\tsendNotification(new FileSearchNotification(requestId, name, size, Id.toString(hash)));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/file/FileTrendNotificationService.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.file;\n\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.common.rest.notification.file.FileTrendNotification;\nimport org.springframework.stereotype.Service;\n\n@Service\npublic class FileTrendNotificationService extends NotificationService\n{\n\tpublic void receivedSearch(String senderName, String keywords)\n\t{\n\t\tsendNotification(new FileTrendNotification(senderName, keywords));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/forum/ForumNotificationService.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.forum;\n\nimport io.xeres.app.service.ForumMessageService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.app.xrs.service.forum.item.ForumGroupItem;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.common.rest.notification.forum.AddOrUpdateForumGroups;\nimport io.xeres.common.rest.notification.forum.AddOrUpdateForumMessages;\nimport io.xeres.common.rest.notification.forum.SetForumGroupMessagesReadState;\nimport io.xeres.common.rest.notification.forum.SetForumMessageReadState;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\nimport static io.xeres.app.database.model.forum.ForumMapper.toDTOs;\nimport static io.xeres.app.database.model.forum.ForumMapper.toForumMessageDTOs;\n\n@Service\npublic class ForumNotificationService extends NotificationService\n{\n\tprivate final ForumMessageService forumMessageService;\n\tprivate final UnHtmlService unHtmlService;\n\n\tpublic ForumNotificationService(ForumMessageService forumMessageService, UnHtmlService unHtmlService)\n\t{\n\t\tsuper();\n\t\tthis.forumMessageService = forumMessageService;\n\t\tthis.unHtmlService = unHtmlService;\n\t}\n\n\tpublic void addOrUpdateGroups(List<ForumGroupItem> groups)\n\t{\n\t\tsendNotification(new AddOrUpdateForumGroups(toDTOs(groups)));\n\t}\n\n\tpublic void addOrUpdateMessages(List<ForumMessageItem> messages)\n\t{\n\t\tsendNotification(new AddOrUpdateForumMessages(toForumMessageDTOs(unHtmlService, messages,\n\t\t\t\tforumMessageService.getAuthorsMapFromMessages(messages),\n\t\t\t\tforumMessageService.getMessagesMapFromMessages(messages),\n\t\t\t\tfalse)));\n\t}\n\n\tpublic void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\tsendNotification(new SetForumMessageReadState(groupId, messageId, read));\n\t}\n\n\tpublic void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tsendNotification(new SetForumGroupMessagesReadState(groupId, read));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/notification/status/StatusNotificationService.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.notification.status;\n\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.service.notification.NotificationService;\nimport io.xeres.common.rest.notification.Notification;\nimport io.xeres.common.rest.notification.status.DhtInfo;\nimport io.xeres.common.rest.notification.status.DhtStatus;\nimport io.xeres.common.rest.notification.status.NatStatus;\nimport io.xeres.common.rest.notification.status.StatusNotification;\nimport org.springframework.stereotype.Service;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\n@Service\npublic class StatusNotificationService extends NotificationService\n{\n\tprivate int currentUserCount;\n\n\tprivate int totalUsers;\n\n\tprivate NatStatus natStatus = NatStatus.UNKNOWN;\n\n\tprivate DhtInfo dhtInfo = DhtInfo.fromStatus(DhtStatus.OFF);\n\n\tprivate final UiBridgeService uiBridgeService;\n\tprivate final ResourceBundle bundle;\n\n\tpublic StatusNotificationService(UiBridgeService uiBridgeService, ResourceBundle bundle)\n\t{\n\t\tsuper();\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\tthis.bundle = bundle;\n\t}\n\n\tpublic void setCurrentUsersCount(int value)\n\t{\n\t\tcurrentUserCount = value;\n\t\tsendNotification(createNotification());\n\t\tuiBridgeService.setTrayStatus(MessageFormat.format(bundle.getString(\"main.systray.peers\"), value));\n\t}\n\n\tpublic void setTotalUsers(int value)\n\t{\n\t\ttotalUsers = value - 1; // We remove our own location\n\t\tsendNotification(createNotification());\n\t}\n\n\tpublic void setNatStatus(NatStatus value)\n\t{\n\t\tnatStatus = value;\n\t\tsendNotification(createNotification());\n\t}\n\n\tpublic void setDhtInfo(DhtInfo value)\n\t{\n\t\tdhtInfo = value;\n\t\tsendNotification(createNotification());\n\t}\n\n\t@Override\n\tprotected Notification initialNotification()\n\t{\n\t\treturn createNotification();\n\t}\n\n\tprivate Notification createNotification()\n\t{\n\t\treturn new StatusNotification(\n\t\t\t\tcurrentUserCount,\n\t\t\t\ttotalUsers,\n\t\t\t\tnatStatus,\n\t\t\t\tdhtInfo\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/script/Console.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.script;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n@SuppressWarnings(\"unused\") // All methods here can be used by JS\npublic class Console\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Console.class);\n\n\tprivate static final String JS_PREFIX = \"[JS]\";\n\n\tpublic void log(String message)\n\t{\n\t\tinfo(message);\n\t}\n\n\tpublic void info(String message)\n\t{\n\t\tlog.info(JS_PREFIX + \" {}\", message);\n\t}\n\n\tpublic void debug(String message)\n\t{\n\t\tlog.debug(JS_PREFIX + \" {}\", message);\n\t}\n\n\tpublic void error(String message)\n\t{\n\t\tlog.error(JS_PREFIX + \" {}\", message);\n\t}\n\n\tpublic void warn(String message)\n\t{\n\t\tlog.warn(JS_PREFIX + \" {}\", message);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/script/ScriptEvent.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.script;\n\nrecord ScriptEvent(String type, Object data)\n{\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ScriptEvent{\" +\n\t\t\t\t\"type='\" + type + '\\'' +\n\t\t\t\t\", data=\" + data +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/script/ScriptService.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.script;\n\nimport io.xeres.app.configuration.DataDirConfiguration;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.MessageService;\nimport io.xeres.app.xrs.service.chat.ChatRsService;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.message.chat.ChatRoomMessage;\nimport jakarta.annotation.PostConstruct;\nimport jakarta.annotation.PreDestroy;\nimport org.graalvm.polyglot.Context;\nimport org.graalvm.polyglot.PolyglotException;\nimport org.graalvm.polyglot.Value;\nimport org.graalvm.polyglot.proxy.ProxyArray;\nimport org.graalvm.polyglot.proxy.ProxyObject;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.core.env.Environment;\nimport org.springframework.core.env.Profiles;\nimport org.springframework.stereotype.Service;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.BlockingQueue;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\nimport static io.xeres.common.message.MessagePath.*;\n\n/// A service to run JS scripts.\n@Service\npublic class ScriptService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ScriptService.class);\n\n\tprivate Context context;\n\tprivate final Map<String, Value> eventHandlers = new ConcurrentHashMap<>();\n\tprivate final AtomicBoolean initialized = new AtomicBoolean(false);\n\tprivate final BlockingQueue<ScriptEvent> eventQueue = new LinkedBlockingQueue<>();\n\tprivate Thread eventProcessorThread;\n\n\tprivate final Environment environment;\n\tprivate final DataDirConfiguration dataDirConfiguration;\n\tprivate final ChatRsService chatRsService;\n\tprivate final MessageService messageService;\n\tprivate final IdentityService identityService;\n\tprivate final LocationService locationService;\n\n\tpublic ScriptService(Environment environment, DataDirConfiguration dataDirConfiguration, @Lazy ChatRsService chatRsService, MessageService messageService, IdentityService identityService, LocationService locationService)\n\t{\n\t\tthis.environment = environment;\n\t\tthis.dataDirConfiguration = dataDirConfiguration;\n\t\tthis.chatRsService = chatRsService;\n\t\tthis.messageService = messageService;\n\t\tthis.identityService = identityService;\n\t\tthis.locationService = locationService;\n\t}\n\n\t@PostConstruct\n\tprivate void init()\n\t{\n\t\tstartContext(false);\n\t}\n\n\t/// Reloads all scripts.\n\tpublic void reload()\n\t{\n\t\tcloseContext();\n\t\tstartContext(true);\n\t}\n\n\tprivate void startContext(boolean throwIfErrors)\n\t{\n\t\tif (initialized.get())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tPath scriptPath;\n\n\t\tif (environment.acceptsProfiles(Profiles.of(\"dev\")))\n\t\t{\n\t\t\tscriptPath = Path.of(\"./scripts/api/user.js\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (dataDirConfiguration.getDataDir() == null) // Don't run for tests\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tscriptPath = Path.of(dataDirConfiguration.getDataDir(), \"Scripts/user.js\");\n\t\t}\n\n\t\tif (!scriptPath.toFile().isFile())\n\t\t{\n\t\t\tlog.info(\"Script file not found: {}\", scriptPath);\n\t\t\treturn;\n\t\t}\n\n\t\tcontext = Context.newBuilder(\"js\")\n\t\t\t\t.option(\"js.strict\", \"true\")\n\t\t\t\t.option(\"js.console\", \"false\")\n\t\t\t\t.allowAllAccess(true)\n\t\t\t\t.build();\n\n\t\tString scriptContent;\n\n\t\ttry\n\t\t{\n\t\t\tscriptContent = new String(Files.readAllBytes(scriptPath));\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Error reading script file: {}\", scriptPath, e);\n\t\t\treturn;\n\t\t}\n\n\t\t// Expose some APIs to the JavaScript script\n\t\tcontext.getBindings(\"js\").putMember(\"xeresAPI\", new XeresAPI());\n\t\tcontext.getBindings(\"js\").putMember(\"console\", new Console());\n\n\t\t// Execute the script\n\t\ttry\n\t\t{\n\t\t\tcontext.eval(\"js\", scriptContent);\n\t\t}\n\t\tcatch (PolyglotException e)\n\t\t{\n\t\t\tif (throwIfErrors)\n\t\t\t{\n\t\t\t\tthrow e;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.error(\"Error in script {}\", scriptPath, e);\n\t\t\t}\n\t\t}\n\t\tinitialized.set(true);\n\t\tstartEventProcessor();\n\t}\n\n\tprivate void startEventProcessor()\n\t{\n\t\t// We use platform threads, because using polyglot contexts on Java virtual threads on HotSpot is experimental in this release,\n\t\t// because access to caller frames in write or materialize mode is not yet supported on virtual threads (some tools and languages depend on that).\n\t\teventProcessorThread = Thread.ofPlatform()\n\t\t\t\t.name(\"JavaScript Runner\")\n\t\t\t\t.start(() -> {\n\t\t\t\t\twhile (initialized.get() && !Thread.currentThread().isInterrupted())\n\t\t\t\t\t{\n\t\t\t\t\t\ttry\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tScriptEvent event = eventQueue.take();\n\t\t\t\t\t\t\tprocessEvent(event);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcatch (InterruptedException _)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tThread.currentThread().interrupt();\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t}\n\n\tpublic void sendEvent(String type, Object data)\n\t{\n\t\tif (!initialized.get())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\teventQueue.add(new ScriptEvent(type, data));\n\t}\n\n\tprivate void processEvent(ScriptEvent event)\n\t{\n\t\ttry\n\t\t{\n\t\t\t// Check if the script has a handler for this event type\n\t\t\tValue handler = eventHandlers.get(event.type());\n\t\t\tif (handler != null && handler.canExecute())\n\t\t\t{\n\t\t\t\t// Convert Java data to JavaScript value\n\t\t\t\tvar jsData = convertToJsValue(event.data());\n\t\t\t\thandler.execute(jsData);\n\t\t\t}\n\t\t}\n\t\tcatch (PolyglotException e)\n\t\t{\n\t\t\tlog.error(\"Error processing event {}\", event, e);\n\t\t}\n\t}\n\n\tprivate Value convertToJsValue(Object data)\n\t{\n\t\tif (data instanceof Map)\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tMap<String, Object> map = (Map<String, Object>) data;\n\t\t\tvar proxyMap = ProxyObject.fromMap(map);\n\t\t\treturn context.asValue(proxyMap);\n\t\t}\n\t\tif (data instanceof List)\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tList<Object> list = (List<Object>) data;\n\t\t\tvar proxyArray = ProxyArray.fromList(list);\n\t\t\treturn context.asValue(proxyArray);\n\t\t}\n\t\treturn context.asValue(data);\n\t}\n\n\t@PreDestroy\n\tprivate void shutdown()\n\t{\n\t\tcloseContext();\n\t}\n\n\tprivate void closeContext()\n\t{\n\t\tinitialized.set(false);\n\t\tif (eventProcessorThread != null)\n\t\t{\n\t\t\teventProcessorThread.interrupt();\n\t\t}\n\t\tif (context != null)\n\t\t{\n\t\t\tcontext.close();\n\t\t}\n\t}\n\n\t/// The Xeres API callable by JS scripts.\n\t@SuppressWarnings(\"unused\") // All methods here can be used by JS\n\tpublic class XeresAPI\n\t{\n\t\t/// Registers an event handler. Those are called by Xeres.\n\t\t///\n\t\t/// @param eventType the event type\n\t\t/// @param handler   the handler\n\t\tpublic void registerEventHandler(String eventType, Value handler)\n\t\t{\n\t\t\teventHandlers.put(eventType, handler);\n\t\t}\n\n\t\t/// Sends a message to a chat room.\n\t\t///\n\t\t/// @param roomId  the room id\n\t\t/// @param message the message\n\t\tpublic void sendChatRoomMessage(long roomId, String message)\n\t\t{\n\t\t\tchatRsService.sendChatRoomMessage(roomId, message);\n\t\t\tmessageService.sendToConsumers(chatRoomDestination(), MessageType.CHAT_ROOM_MESSAGE, roomId, new ChatRoomMessage(identityService.getOwnIdentity().getName(), identityService.getOwnIdentity().getGxsId(), message));\n\t\t}\n\n\t\t/// Sends a private chat message.\n\t\t///\n\t\t/// @param destination the destination (location)\n\t\t/// @param message     the message\n\t\tpublic void sendPrivateMessage(String destination, String message)\n\t\t{\n\t\t\tvar location = LocationIdentifier.fromString(destination);\n\t\t\tchatRsService.sendPrivateMessage(location, message);\n\t\t\tvar chatMessage = new ChatMessage(message);\n\t\t\tchatMessage.setOwn(true);\n\t\t\tmessageService.sendToConsumers(chatPrivateDestination(), MessageType.CHAT_PRIVATE_MESSAGE, location, chatMessage);\n\t\t}\n\n\t\t/// Sends a distant chat message.\n\t\t///\n\t\t/// @param destination the destination (gxsId)\n\t\t/// @param message     the message\n\t\tpublic void sendDistantMessage(String destination, String message)\n\t\t{\n\t\t\tvar gxsId = GxsId.fromString(destination);\n\t\t\tchatRsService.sendPrivateMessage(gxsId, message);\n\t\t\tvar chatMessage = new ChatMessage(message);\n\t\t\tchatMessage.setOwn(true);\n\t\t\tmessageService.sendToConsumers(chatDistantDestination(), MessageType.CHAT_PRIVATE_MESSAGE, gxsId, chatMessage);\n\t\t}\n\n\t\tpublic String getAvailability()\n\t\t{\n\t\t\treturn locationService.findOwnLocation().orElseThrow().getAvailability().name();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/shell/History.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.shell;\n\nimport java.util.LinkedList;\n\nclass History\n{\n\tprivate final LinkedList<String> historyList = new LinkedList<>();\n\tprivate final int maxSize;\n\tprivate int currentIndex;\n\n\tpublic History(int maxSize)\n\t{\n\t\tthis.maxSize = maxSize;\n\t\tcurrentIndex = -1;\n\t}\n\n\tpublic void addCommand(String command)\n\t{\n\t\tif (command == null || command.trim().isEmpty())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\t// Remove duplicates\n\t\thistoryList.remove(command);\n\n\t\t// Add to front\n\t\thistoryList.addFirst(command);\n\n\t\t// Maintain size\n\t\tif (historyList.size() > maxSize)\n\t\t{\n\t\t\thistoryList.removeLast();\n\t\t}\n\n\t\tcurrentIndex = -1;\n\t}\n\n\tpublic String getPrevious()\n\t{\n\t\tif (historyList.isEmpty())\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tif (currentIndex < historyList.size() - 1)\n\t\t{\n\t\t\tcurrentIndex++;\n\t\t\treturn historyList.get(currentIndex);\n\t\t}\n\n\t\treturn historyList.getLast();\n\t}\n\n\tpublic String getNext()\n\t{\n\t\tif (historyList.isEmpty() || currentIndex <= 0)\n\t\t{\n\t\t\tcurrentIndex = -1;\n\t\t\treturn null;\n\t\t}\n\n\t\tcurrentIndex--;\n\t\treturn historyList.get(currentIndex);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/service/shell/ShellService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.shell;\n\nimport ch.qos.logback.classic.Level;\nimport ch.qos.logback.classic.LoggerContext;\nimport io.xeres.app.service.InfoService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.script.ScriptService;\nimport io.xeres.app.xrs.service.forum.ForumRsService;\nimport io.xeres.app.xrs.service.gxs.GxsHelperService;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.mui.MUI;\nimport io.xeres.common.mui.Shell;\nimport io.xeres.common.mui.ShellResult;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.common.util.OsUtils;\nimport jakarta.annotation.PreDestroy;\nimport org.apache.commons.lang3.StringUtils;\nimport org.graalvm.polyglot.PolyglotException;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.DefaultApplicationArguments;\nimport org.springframework.stereotype.Service;\n\nimport java.io.File;\nimport java.io.PrintWriter;\nimport java.io.StringWriter;\nimport java.nio.file.Path;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.common.mui.ShellAction.*;\n\n@Service\npublic class ShellService implements Shell\n{\n\tprivate final History history = new History(20);\n\n\tprivate final ScriptService scriptService;\n\tprivate final ForumRsService forumRsService;\n\tprivate final GxsHelperService<?, ?> gxsHelperService;\n\tprivate final LocationService locationService;\n\tprivate final InfoService infoService;\n\n\tpublic ShellService(ScriptService scriptService, ForumRsService forumRsService, GxsHelperService<?, ?> gxsHelperService, LocationService locationService, InfoService infoService)\n\t{\n\t\tthis.scriptService = scriptService;\n\t\tthis.forumRsService = forumRsService;\n\t\tthis.gxsHelperService = gxsHelperService;\n\t\tthis.locationService = locationService;\n\t\tthis.infoService = infoService;\n\t}\n\n\t@Override\n\tpublic ShellResult sendCommand(String input)\n\t{\n\t\tvar args = new DefaultApplicationArguments(translateCommandline(input));\n\t\tvar arg = args.getNonOptionArgs().isEmpty() ? null : args.getNonOptionArgs().getFirst();\n\n\t\tif (StringUtils.isAsciiPrintable(arg))\n\t\t{\n\t\t\thistory.addCommand(input);\n\t\t\ttry\n\t\t\t{\n\t\t\t\treturn switch (arg.toLowerCase(Locale.ROOT))\n\t\t\t\t{\n\t\t\t\t\tcase \"help\", \"?\" -> new ShellResult(SUCCESS, \"\"\"\n\t\t\t\t\t\t\tAvailable commands:\n\t\t\t\t\t\t\t  - help: displays this help\n\t\t\t\t\t\t\t  - avail: shows the available memory\n\t\t\t\t\t\t\t  - clear: clears the screen\n\t\t\t\t\t\t\t  - cpu: shows the CPU count\n\t\t\t\t\t\t\t  - exit: closes the shell\n\t\t\t\t\t\t\t  - fix_forum_duplicates: fix forum duplicates\n\t\t\t\t\t\t\t  - gc: runs the garbage collector\n\t\t\t\t\t\t\t  - loglevel [package] [level]: sets the log level of a package\n\t\t\t\t\t\t\t  - logs: shows the logs\n\t\t\t\t\t\t\t  - open: opens a directory (app, cache, data or download)\n\t\t\t\t\t\t\t  - properties: shows the properties\n\t\t\t\t\t\t\t  - pwd: shows the current directory\n\t\t\t\t\t\t\t  - reset_last_peer_message_update [location identifier] [group gxs identifier] [service id]: resets the last peer message update\n\t\t\t\t\t\t\t  - reload: reloads user scripts\n\t\t\t\t\t\t\t  - uname: shows the operating system\n\t\t\t\t\t\t\t  - uptime: shows the app uptime\"\"\");\n\t\t\t\t\tcase \"exit\", \"endshell\", \"endcli\" -> new ShellResult(EXIT);\n\t\t\t\t\tcase \"clear\", \"cls\" -> new ShellResult(CLS);\n\t\t\t\t\tcase \"avail\", \"free\" -> getMemorySpecs();\n\t\t\t\t\tcase \"cpu\" -> getCpuCount();\n\t\t\t\t\tcase \"pwd\", \"cd\" -> getWorkingDirectory();\n\t\t\t\t\tcase \"properties\", \"props\" -> getProperties();\n\t\t\t\t\tcase \"uname\" -> getOperatingSystem();\n\t\t\t\t\tcase \"uptime\" -> getUptime();\n\t\t\t\t\tcase \"gc\" -> runGc();\n\t\t\t\t\tcase \"loglevel\" -> setLogLevel(getArgument(args, 1), getArgument(args, 2));\n\t\t\t\t\tcase \"logs\" -> showLogs();\n\t\t\t\t\tcase \"open\" -> openDirectory(getArgument(args, 1));\n\t\t\t\t\tcase \"loadwb\" -> new ShellResult(SUCCESS, \"Not again!\");\n\t\t\t\t\tcase \"reload\" -> reload();\n\t\t\t\t\tcase \"fix_forum_duplicates\" -> fixForumDuplicates();\n\t\t\t\t\tcase \"reset_last_peer_message_update\" -> resetLastPeerMessageUpdate(getArgument(args, 1), getArgument(args, 2), getArgument(args, 3));\n\t\t\t\t\tdefault -> new ShellResult(UNKNOWN_COMMAND, arg);\n\t\t\t\t};\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t{\n\t\t\t\treturn new ShellResult(ERROR, e.getMessage());\n\t\t\t}\n\t\t}\n\t\treturn new ShellResult(NO_OP);\n\t}\n\n\t/**\n\t * Gets the argument.\n\t *\n\t * @param args  the arguments\n\t * @param index the index of the argument, 0 for the command name, 1 for the first argument, etc...\n\t * @return the argument or null if it wasn't supplied\n\t */\n\tprivate String getArgument(DefaultApplicationArguments args, int index)\n\t{\n\t\treturn args.getNonOptionArgs().size() > index ? args.getNonOptionArgs().get(index) : null;\n\t}\n\n\t@Override\n\tpublic String getPreviousCommand()\n\t{\n\t\treturn history.getPrevious();\n\t}\n\n\t@Override\n\tpublic String getNextCommand()\n\t{\n\t\treturn history.getNext();\n\t}\n\n\tprivate ShellResult getProperties()\n\t{\n\t\tvar properties = System.getProperties();\n\n\t\tvar map = properties.entrySet().stream()\n\t\t\t\t.collect(Collectors.toMap(k -> (String) k.getKey(), e -> (String) e.getValue()))\n\t\t\t\t.entrySet().stream()\n\t\t\t\t.sorted(Map.Entry.comparingByKey())\n\t\t\t\t.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,\n\t\t\t\t\t\t(oldValue, _) -> oldValue, LinkedHashMap::new));\n\n\t\tvar sb = new StringBuilder();\n\t\tmap.forEach((key, value) -> processProperty(sb, key, value));\n\n\t\treturn new ShellResult(SUCCESS, sb.toString());\n\t}\n\n\tprivate static void processProperty(StringBuilder sb, String key, String value)\n\t{\n\t\tif (key.endsWith(\".path\"))\n\t\t{\n\t\t\tvalue = String.join(\"\\n\", value.split(File.pathSeparator));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvalue = showLineSeparator(value);\n\t\t}\n\t\tsb.append(key).append(\" = \").append(value).append(\"\\n\");\n\t}\n\n\tprivate static String showLineSeparator(String in)\n\t{\n\t\tin = in.replace(\"\\n\", \"\\\\n\");\n\t\tin = in.replace(\"\\r\", \"\\\\r\");\n\t\treturn in;\n\t}\n\n\tprivate static ShellResult getMemorySpecs()\n\t{\n\t\tvar totalMemory = Runtime.getRuntime().totalMemory();\n\t\treturn new ShellResult(SUCCESS,\n\t\t\t\t\"Memory allocated for the JVM: \" + ByteUnitUtils.fromBytes(totalMemory) + \"\\n\" +\n\t\t\t\t\t\t\"Used memory: \" + ByteUnitUtils.fromBytes(totalMemory - Runtime.getRuntime().freeMemory()) + \"\\n\" +\n\t\t\t\t\t\t\"Maximum allocatable memory: \" + ByteUnitUtils.fromBytes(Runtime.getRuntime().maxMemory()));\n\t}\n\n\tprivate ShellResult getUptime()\n\t{\n\t\tvar duration = infoService.getUptime();\n\n\t\tvar days = duration.toDays();\n\t\tvar hours = duration.toHours() % 24;\n\t\tvar minutes = duration.toMinutes() % 60;\n\t\tvar seconds = duration.getSeconds() % 60;\n\n\t\treturn new ShellResult(SUCCESS,\n\t\t\t\tString.format(\"%d days, %d hours, %d minutes, %d seconds\",\n\t\t\t\t\t\tdays, hours, minutes, seconds));\n\t}\n\n\tprivate static ShellResult getCpuCount()\n\t{\n\t\treturn new ShellResult(SUCCESS,\n\t\t\t\t\"CPU count: \" + Runtime.getRuntime().availableProcessors());\n\t}\n\n\tprivate static ShellResult getWorkingDirectory()\n\t{\n\t\treturn new ShellResult(SUCCESS, System.getProperty(\"user.dir\"));\n\t}\n\n\tprivate static ShellResult getOperatingSystem()\n\t{\n\t\treturn new ShellResult(SUCCESS, System.getProperty(\"os.name\") + \" (\" + System.getProperty(\"os.arch\") + \")\");\n\t}\n\n\tprivate static ShellResult runGc()\n\t{\n\t\tSystem.gc();\n\t\treturn new ShellResult(SUCCESS, \"Done\");\n\t}\n\n\tprivate static ShellResult setLogLevel(String packageName, String level)\n\t{\n\t\tif (StringUtils.isBlank(packageName))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"package name must be provided (eg. io.xeres.app.application.Startup)\");\n\t\t}\n\t\tif (StringUtils.isBlank(level))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"log level must be provided (trace, debug, info, warn, error)\");\n\t\t}\n\n\t\tvar loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();\n\t\tvar logger = loggerContext.exists(packageName);\n\t\tObjects.requireNonNull(logger, \"no such logger\");\n\n\t\tlogger.setLevel(Level.valueOf(level));\n\n\t\treturn new ShellResult(SUCCESS, \"Level of \" + logger.getName() + \" changed to \" + logger.getLevel());\n\t}\n\n\tprivate static ShellResult showLogs()\n\t{\n\t\tOsUtils.shellOpen(OsUtils.getLogFile().toFile());\n\t\treturn new ShellResult(SUCCESS, \"Showing logs in external viewer\");\n\t}\n\n\tprivate static ShellResult openDirectory(String name)\n\t{\n\t\tPath directory;\n\n\t\tdirectory = switch (name)\n\t\t{\n\t\t\tcase \"app\" -> OsUtils.getApplicationHome();\n\t\t\tcase \"cache\" -> OsUtils.getCacheDir();\n\t\t\tcase \"data\" -> OsUtils.getDataDir();\n\t\t\tcase \"download\" -> OsUtils.getDownloadDir();\n\t\t\tcase null, default -> null;\n\t\t};\n\n\t\tObjects.requireNonNull(directory, \"Invalid directory name. Must be either 'app', 'cache, 'data' or 'download'\");\n\n\t\tOsUtils.showFolder(directory.toFile());\n\n\t\treturn new ShellResult(SUCCESS, \"Opening \" + name + \" directory at \" + directory + \" ...\");\n\t}\n\n\tprivate ShellResult reload()\n\t{\n\t\ttry\n\t\t{\n\t\t\tscriptService.reload();\n\t\t}\n\t\tcatch (PolyglotException e)\n\t\t{\n\t\t\tvar sw = new StringWriter();\n\t\t\tvar pw = new PrintWriter(sw);\n\t\t\te.printStackTrace(pw);\n\t\t\treturn new ShellResult(ERROR, \"Reload failed: \" + sw);\n\t\t}\n\t\treturn new ShellResult(SUCCESS, \"Reloaded\");\n\t}\n\n\tprivate ShellResult fixForumDuplicates()\n\t{\n\t\tforumRsService.fixDuplicates();\n\t\treturn new ShellResult(SUCCESS, \"Fixed forum duplicates\");\n\t}\n\n\tprivate ShellResult resetLastPeerMessageUpdate(String locationString, String gxsIdString, String serviceTypeString)\n\t{\n\t\tvar location = Objects.requireNonNull(locationService.findLocationByLocationIdentifier(LocationIdentifier.fromString(locationString)).orElse(null), \"Invalid location identifier\");\n\t\tvar gxsId = GxsId.fromString(gxsIdString);\n\t\tif (gxsId.isNullIdentifier())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Invalid group identifier\");\n\t\t}\n\t\tvar rsServiceType = RsServiceType.fromName(serviceTypeString);\n\t\tif (rsServiceType == RsServiceType.NONE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Invalid service type, must be one of \" + Arrays.stream(RsServiceType.values())\n\t\t\t\t\t.sorted()\n\t\t\t\t\t.map(Enum::name)\n\t\t\t\t\t.filter(s -> s.startsWith(\"GXS_\"))\n\t\t\t\t\t.collect(Collectors.joining(\", \")));\n\t\t}\n\n\t\tgxsHelperService.setLastPeerMessageUpdate(location, gxsId, Instant.EPOCH, rsServiceType);\n\n\t\treturn new ShellResult(SUCCESS, \"Successfully reset peer update time\");\n\t}\n\n\t/**\n\t * [code borrowed from ant.jar]\n\t * Crack a command line.\n\t *\n\t * @param toProcess the command line to process.\n\t * @return the command line broken into strings.\n\t * An empty or null toProcess parameter results in a zero sized array.\n\t */\n\tstatic String[] translateCommandline(String toProcess)\n\t{\n\t\tenum State\n\t\t{\n\t\t\tNORMAL,\n\t\t\tIN_QUOTE,\n\t\t\tIN_DOUBLE_QUOTE\n\t\t}\n\n\t\tif (StringUtils.isEmpty(toProcess))\n\t\t{\n\t\t\t//no command? no string\n\t\t\treturn new String[0];\n\t\t}\n\t\t// parse with a simple finite state machine\n\n\t\tvar state = State.NORMAL;\n\t\tfinal var tok = new StringTokenizer(toProcess, \"\\\"' \", true);\n\t\tfinal var current = new StringBuilder();\n\t\tfinal ArrayList<String> result = new ArrayList<>();\n\t\tvar lastTokenHasBeenQuoted = false;\n\n\t\twhile (tok.hasMoreTokens())\n\t\t{\n\t\t\tString nextTok = tok.nextToken();\n\t\t\tswitch (state)\n\t\t\t{\n\t\t\t\tcase State.IN_QUOTE ->\n\t\t\t\t{\n\t\t\t\t\tif (\"'\".equals(nextTok))\n\t\t\t\t\t{\n\t\t\t\t\t\tlastTokenHasBeenQuoted = true;\n\t\t\t\t\t\tstate = State.NORMAL;\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tcurrent.append(nextTok);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcase State.IN_DOUBLE_QUOTE ->\n\t\t\t\t{\n\t\t\t\t\tif (\"\\\"\".equals(nextTok))\n\t\t\t\t\t{\n\t\t\t\t\t\tlastTokenHasBeenQuoted = true;\n\t\t\t\t\t\tstate = State.NORMAL;\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tcurrent.append(nextTok);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdefault ->\n\t\t\t\t{\n\t\t\t\t\tswitch (nextTok)\n\t\t\t\t\t{\n\t\t\t\t\t\tcase \"'\" -> state = State.IN_QUOTE;\n\t\t\t\t\t\tcase \"\\\"\" -> state = State.IN_DOUBLE_QUOTE;\n\t\t\t\t\t\tcase \" \" ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif (lastTokenHasBeenQuoted || !current.isEmpty())\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tresult.add(current.toString());\n\t\t\t\t\t\t\t\tcurrent.setLength(0);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase null, default -> current.append(nextTok);\n\t\t\t\t\t}\n\t\t\t\t\tlastTokenHasBeenQuoted = false;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (lastTokenHasBeenQuoted || !current.isEmpty())\n\t\t{\n\t\t\tresult.add(current.toString());\n\t\t}\n\t\tif (state == State.IN_QUOTE || state == State.IN_DOUBLE_QUOTE)\n\t\t{\n\t\t\tthrow new RuntimeException(\"unbalanced quotes in \" + toProcess);\n\t\t}\n\t\treturn result.toArray(new String[0]);\n\t}\n\n\t@PreDestroy\n\tprivate void cleanup()\n\t{\n\t\tMUI.closeShell();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/DevUtils.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util;\n\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\n\npublic final class DevUtils\n{\n\tprivate DevUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String getDirFromDevelopmentSetup(String directory)\n\t{\n\t\t// Find out if we're running from rootProject, which means\n\t\t// we have an 'app' folder in there.\n\t\t// We use a relative directory because currentDir is not supposed\n\t\t// to change, and it looks clearer.\n\t\tvar appDir = Path.of(\"app\");\n\t\tif (Files.exists(appDir))\n\t\t{\n\t\t\treturn Path.of(\".\", directory).toString();\n\t\t}\n\t\tappDir = Path.of(\"..\", \"app\");\n\t\tif (Files.exists(appDir))\n\t\t{\n\t\t\treturn Path.of(\"..\", directory).toString();\n\t\t}\n\t\tthrow new IllegalStateException(\"Unable to find/create directory. Current directory must be the project's root directory or 'app'. It is \" + Paths.get(\"\").toAbsolutePath());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/GxsUtils.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util;\n\nimport io.xeres.common.util.image.ImageUtils;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport javax.imageio.ImageIO;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\n\npublic final class GxsUtils\n{\n\tpublic static final long IMAGE_MAX_INPUT_SIZE = 1024 * 1024 * 10L; // 10 MB;\n\n\tpublic static final int MAXIMUM_GXS_MESSAGE_SIZE = 199_000;\n\n\tprivate GxsUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Gets a scaled image for GxS groups.\n\t *\n\t * @param imageFile the image file\n\t * @param sideSize  the side size, usually 64 pixels\n\t * @return a scaled image array\n\t * @throws IOException if there's an I/O error\n\t */\n\tpublic static byte[] getScaledGroupImage(MultipartFile imageFile, int sideSize) throws IOException\n\t{\n\t\tif (imageFile == null || imageFile.isEmpty())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Image is empty\");\n\t\t}\n\n\t\tif (imageFile.getSize() >= IMAGE_MAX_INPUT_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Image file size is bigger than \" + IMAGE_MAX_INPUT_SIZE + \" bytes\");\n\t\t}\n\n\t\tvar image = ImageUtils.setImageSquareAndCrop(ImageIO.read(imageFile.getInputStream()), sideSize);\n\t\tvar imageOut = new ByteArrayOutputStream();\n\t\tif (ImageUtils.isPossiblyTransparent(imageFile.getContentType()))\n\t\t{\n\t\t\tif (!ImageUtils.writeImageAsPng(image, MAXIMUM_GXS_MESSAGE_SIZE - 2000, imageOut))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Couldn't write the image. Unsupported format (transparent)?\");\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (!ImageUtils.writeImageAsJpeg(image, MAXIMUM_GXS_MESSAGE_SIZE - 2000, imageOut))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Couldn't write the image. Unsupported format (non-transparent)?\");\n\t\t\t}\n\t\t}\n\t\treturn imageOut.toByteArray();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/XmlUtils.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util;\n\nimport javax.xml.XMLConstants;\nimport javax.xml.parsers.DocumentBuilderFactory;\nimport javax.xml.stream.XMLInputFactory;\n\npublic final class XmlUtils\n{\n\tprivate XmlUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static DocumentBuilderFactory getSecureDocumentBuilderFactory()\n\t{\n\t\tvar df = DocumentBuilderFactory.newInstance();\n\t\tdf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, \"\");\n\t\tdf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, \"\");\n\t\treturn df;\n\t}\n\n\tpublic static XMLInputFactory getSecureXMLInputFactory()\n\t{\n\t\tvar xmlInputFactory = XMLInputFactory.newFactory();\n\t\txmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false);\n\t\txmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);\n\t\treturn xmlInputFactory;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/CompoundExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\nimport java.util.List;\n\n/**\n * Matches 2 expressions, ANDed, ORed or XORed together.\n */\npublic class CompoundExpression implements Expression\n{\n\tpublic enum Operator\n\t{\n\t\tAND,\n\t\tOR,\n\t\tXOR\n\t}\n\n\tprivate final Operator operator;\n\tprivate final Expression left;\n\tprivate final Expression right;\n\n\tpublic CompoundExpression(Operator operator, Expression left, Expression right)\n\t{\n\t\tthis.operator = operator;\n\t\tthis.left = left;\n\t\tthis.right = right;\n\t}\n\n\t@Override\n\tpublic boolean evaluate(File file)\n\t{\n\t\tif (left == null || right == null)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase AND -> left.evaluate(file) && right.evaluate(file);\n\t\t\tcase OR -> left.evaluate(file) || right.evaluate(file);\n\t\t\tcase XOR -> left.evaluate(file) ^ right.evaluate(file);\n\t\t};\n\t}\n\n\t@Override\n\tpublic Predicate toPredicate(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase AND -> cb.and(left.toPredicate(cb, root), right.toPredicate(cb, root));\n\t\t\tcase OR -> cb.or(left.toPredicate(cb, root), right.toPredicate(cb, root));\n\t\t\tcase XOR ->\n\t\t\t{\n\t\t\t\tvar l = left.toPredicate(cb, root);\n\t\t\t\tvar r = right.toPredicate(cb, root);\n\t\t\t\tyield cb.or(cb.and(l, cb.not(r)), cb.and(cb.not(l), r));\n\t\t\t}\n\t\t};\n\t}\n\n\t@Override\n\tpublic void linearize(List<Byte> tokens, List<Integer> ints, List<String> strings)\n\t{\n\t\ttokens.add(ExpressionType.getTokenValueByClass(getClass()));\n\t\tints.add(operator.ordinal());\n\t\tleft.linearize(tokens, ints, strings);\n\t\tright.linearize(tokens, ints, strings);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase AND -> \"(\" + left + \") AND (\" + right + \")\";\n\t\t\tcase OR -> \"(\" + left + \") OR (\" + right + \")\";\n\t\t\tcase XOR -> \"(\" + left + \") XOR (\" + right + \")\";\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/DateExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\n\n/**\n * Matches the last modified field of the file. This is what is reported by the filesystem\n * and not the metadata of the file, and is hence not very reliable.\n */\npublic class DateExpression extends RelationalExpression\n{\n\tpublic DateExpression(Operator operator, int lowerValue, int higherValue)\n\t{\n\t\tsuper(operator, lowerValue, higherValue);\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"DATE\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"modified\";\n\t}\n\n\t@Override\n\tpublic Predicate toPredicate(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\t// Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS -> cb.equal(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue));\n\t\t\tcase GREATER_THAN_OR_EQUALS -> cb.lessThanOrEqualTo(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue));\n\t\t\tcase GREATER_THAN -> cb.lessThan(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue));\n\t\t\tcase LESSER_THAN_OR_EQUALS -> cb.greaterThanOrEqualTo(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue));\n\t\t\tcase LESSER_THAN -> cb.greaterThan(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue));\n\t\t\tcase IN_RANGE -> cb.between(root.get(getDatabaseColumnName()), Instant.ofEpochSecond(lowerValue), Instant.ofEpochSecond(higherValue));\n\t\t};\n\t}\n\n\t@Override\n\tint getValue(File file)\n\t{\n\t\treturn (int) file.getModified().truncatedTo(ChronoUnit.SECONDS).getEpochSecond();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/Expression.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\nimport java.util.List;\n\npublic interface Expression\n{\n\tboolean evaluate(File file);\n\n\tvoid linearize(List<Byte> tokens, List<Integer> ints, List<String> strings);\n\n\tPredicate toPredicate(CriteriaBuilder cb, Root<File> root);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/ExpressionMapper.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.xrs.service.turtle.item.TurtleRegExpSearchRequestItem;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.StringJoiner;\n\npublic final class ExpressionMapper\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ExpressionMapper.class);\n\n\tprivate ExpressionMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static class Context\n\t{\n\t\tprivate final List<Byte> tokens;\n\t\tprivate final List<Integer> ints;\n\t\tprivate final List<String> strings;\n\t\tprivate int tokenIndex;\n\t\tprivate int integerIndex;\n\t\tprivate int stringIndex;\n\n\t\tpublic Context(List<Byte> tokens, List<Integer> ints, List<String> strings)\n\t\t{\n\t\t\tthis.tokens = tokens;\n\t\t\tthis.ints = ints;\n\t\t\tthis.strings = strings;\n\t\t}\n\n\t\tpublic boolean hasNextToken()\n\t\t{\n\t\t\treturn tokenIndex < tokens.size();\n\t\t}\n\n\t\tpublic ExpressionType nextToken()\n\t\t{\n\t\t\treturn ExpressionType.values()[tokens.get(tokenIndex++)];\n\t\t}\n\n\t\tpublic int nextIntegerValue()\n\t\t{\n\t\t\treturn ints.get(integerIndex++);\n\t\t}\n\n\t\tpublic void skipIntegerValue()\n\t\t{\n\t\t\tintegerIndex++;\n\t\t}\n\n\t\tpublic String nextStringValue()\n\t\t{\n\t\t\treturn strings.get(stringIndex++);\n\t\t}\n\t}\n\n\tpublic static List<Expression> toExpressions(TurtleRegExpSearchRequestItem item)\n\t{\n\t\tvar context = new Context(item.getTokens(), item.getInts(), item.getStrings());\n\t\tList<Expression> expressions = new ArrayList<>();\n\n\t\ttry\n\t\t{\n\t\t\twhile (context.hasNextToken())\n\t\t\t{\n\t\t\t\texpressions.add(toExpression(context));\n\t\t\t}\n\t\t}\n\t\tcatch (IndexOutOfBoundsException | IllegalStateException e)\n\t\t{\n\t\t\tlog.error(\"Expression error: {} for the following token input: tokens {}, ints {}, strings {}\",\n\t\t\t\t\te.getMessage(),\n\t\t\t\t\tArrays.toString(item.getTokens().toArray()),\n\t\t\t\t\tArrays.toString(item.getInts().toArray()),\n\t\t\t\t\tArrays.toString(item.getStrings().toArray()));\n\t\t\treturn List.of();\n\t\t}\n\t\treturn expressions;\n\t}\n\n\tpublic static TurtleRegExpSearchRequestItem toItem(List<Expression> expressions)\n\t{\n\t\tList<Byte> tokens = new ArrayList<>();\n\t\tList<Integer> ints = new ArrayList<>();\n\t\tList<String> strings = new ArrayList<>();\n\n\t\tfor (var expression : expressions)\n\t\t{\n\t\t\texpression.linearize(tokens, ints, strings);\n\t\t}\n\t\treturn new TurtleRegExpSearchRequestItem(tokens, ints, strings);\n\t}\n\n\tprivate static Expression toExpression(Context context)\n\t{\n\t\tvar token = context.nextToken();\n\t\treturn switch (token)\n\t\t{\n\t\t\tcase DATE -> toDateExpression(context);\n\t\t\tcase POPULARITY -> toPopularityExpression(context);\n\t\t\tcase SIZE -> toSizeExpression(context);\n\t\t\tcase SIZE_MB -> toSizeMbExpression(context);\n\t\t\tcase NAME -> toNameExpression(context);\n\t\t\tcase PATH -> toPathExpression(context);\n\t\t\tcase EXTENSION -> toExtensionExpression(context);\n\t\t\tcase HASH -> toHashExpression(context);\n\t\t\tcase COMPOUND -> toCompoundExpression(context);\n\t\t};\n\t}\n\n\tprivate static DateExpression toDateExpression(Context context)\n\t{\n\t\tvar operator = RelationalExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar lowerValue = context.nextIntegerValue();\n\t\tvar higherValue = context.nextIntegerValue();\n\t\treturn new DateExpression(operator, lowerValue, higherValue);\n\t}\n\n\tprivate static PopularityExpression toPopularityExpression(Context context)\n\t{\n\t\tvar operator = RelationalExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar lowerValue = context.nextIntegerValue();\n\t\tvar higherValue = context.nextIntegerValue();\n\t\treturn new PopularityExpression(operator, lowerValue, higherValue);\n\t}\n\n\tprivate static SizeExpression toSizeExpression(Context context)\n\t{\n\t\tvar operator = RelationalExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar lowerValue = context.nextIntegerValue();\n\t\tvar higherValue = context.nextIntegerValue();\n\t\treturn new SizeExpression(operator, lowerValue, higherValue);\n\t}\n\n\tprivate static SizeMbExpression toSizeMbExpression(Context context)\n\t{\n\t\tvar operator = RelationalExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar lowerValue = context.nextIntegerValue();\n\t\tvar higherValue = context.nextIntegerValue();\n\t\treturn new SizeMbExpression(operator, lowerValue, higherValue);\n\t}\n\n\tprivate static NameExpression toNameExpression(Context context)\n\t{\n\t\tvar operator = StringExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar caseSensitive = context.nextIntegerValue() == 0;\n\t\tvar stringsSize = context.nextIntegerValue();\n\t\tvar sb = new StringJoiner(\" \");\n\n\t\twhile (stringsSize-- > 0)\n\t\t{\n\t\t\tsb.add(context.nextStringValue());\n\t\t}\n\t\treturn new NameExpression(operator, sb.toString(), caseSensitive);\n\t}\n\n\tprivate static PathExpression toPathExpression(Context context)\n\t{\n\t\tvar operator = StringExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar caseSensitive = context.nextIntegerValue() == 0;\n\t\tvar stringsSize = context.nextIntegerValue();\n\t\tvar sb = new StringJoiner(\" \");\n\n\t\twhile (stringsSize-- > 0)\n\t\t{\n\t\t\tsb.add(context.nextStringValue());\n\t\t}\n\t\treturn new PathExpression(operator, sb.toString(), caseSensitive);\n\t}\n\n\tprivate static ExtensionExpression toExtensionExpression(Context context)\n\t{\n\t\tvar operator = StringExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar caseSensitive = context.nextIntegerValue() == 0;\n\t\tvar stringsSize = context.nextIntegerValue();\n\t\tvar sb = new StringJoiner(\" \");\n\n\t\twhile (stringsSize-- > 0)\n\t\t{\n\t\t\tsb.add(context.nextStringValue());\n\t\t}\n\t\treturn new ExtensionExpression(operator, sb.toString(), caseSensitive);\n\t}\n\n\tprivate static HashExpression toHashExpression(Context context)\n\t{\n\t\tvar operator = StringExpression.Operator.values()[context.nextIntegerValue()];\n\t\tcontext.skipIntegerValue(); // No case sensitivity needed\n\t\tvar stringsSize = context.nextIntegerValue();\n\t\tvar sb = new StringJoiner(\" \");\n\n\t\twhile (stringsSize-- > 0)\n\t\t{\n\t\t\tsb.add(context.nextStringValue());\n\t\t}\n\t\treturn new HashExpression(operator, sb.toString());\n\t}\n\n\tprivate static CompoundExpression toCompoundExpression(Context context)\n\t{\n\t\tvar operator = CompoundExpression.Operator.values()[context.nextIntegerValue()];\n\t\tvar leftCompound = toExpression(context);\n\t\tvar rightCompound = toExpression(context);\n\n\t\treturn new CompoundExpression(operator, leftCompound, rightCompound);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/ExpressionType.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport java.util.Arrays;\n\nenum ExpressionType\n{\n\t// The order and value matters\n\tDATE(DateExpression.class),\n\tPOPULARITY(PopularityExpression.class),\n\tSIZE(SizeExpression.class),\n\tHASH(HashExpression.class),\n\tNAME(NameExpression.class),\n\tPATH(PathExpression.class),\n\tEXTENSION(ExtensionExpression.class),\n\tCOMPOUND(CompoundExpression.class),\n\tSIZE_MB(SizeMbExpression.class);\n\n\tprivate final Class<? extends Expression> javaClass;\n\n\tExpressionType(Class<? extends Expression> javaClass)\n\t{\n\t\tthis.javaClass = javaClass;\n\t}\n\n\tstatic byte getTokenValueByClass(Class<? extends Expression> javaClass)\n\t{\n\t\treturn (byte) Arrays.stream(values())\n\t\t\t\t.filter(expressionType -> expressionType.javaClass.equals(javaClass))\n\t\t\t\t.findFirst().orElseThrow()\n\t\t\t\t.ordinal();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/ExtensionExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.common.util.FileNameUtils;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * Matches the extension of a file. This implementation deviates a little by matching over\n * the whole name but uses some tricks to make it acceptable in most common cases.\n */\npublic class ExtensionExpression extends StringExpression\n{\n\tpublic ExtensionExpression(@SuppressWarnings(\"unused\") Operator operator, String template, boolean caseSensitive)\n\t{\n\t\tsuper(Operator.CONTAINS_ANY, template, caseSensitive);\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"EXTENSION\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"name\";\n\t}\n\n\t@Override\n\tpublic Predicate toPredicate(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\treturn contains(cb, root);\n\t}\n\n\tprivate Predicate contains(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\tList<Predicate> predicates = new ArrayList<>();\n\t\twords.forEach(s -> predicates.add(like(cb, root.get(getDatabaseColumnName()), \".\" + s)));\n\t\tvar array = predicates.toArray(new Predicate[0]);\n\t\treturn cb.or(array);\n\t}\n\n\t@Override\n\tString getValue(File file)\n\t{\n\t\treturn FileNameUtils.getExtension(file.getName()).orElse(\"\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/HashExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\n\n/**\n * Matches the hash of the file but doesn't work yet.\n */\npublic class HashExpression extends StringExpression\n{\n\tpublic HashExpression(Operator operator, String template)\n\t{\n\t\tsuper(operator, template, true);\n\t}\n\n\t@Override\n\tboolean isEnabled()\n\t{\n\t\treturn false; // Criteria API doesn't seem to support byte arrays so we just fail for now\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"HASH\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"hash\";\n\t}\n\n\t@Override\n\tString getValue(File file)\n\t{\n\t\treturn file.getHash().toString();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/NameExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\n\n/**\n * Matches the name of the file.\n */\npublic class NameExpression extends StringExpression\n{\n\tpublic NameExpression(Operator operator, String template, boolean caseSensitive)\n\t{\n\t\tsuper(operator, template, caseSensitive);\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"NAME\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"name\";\n\t}\n\n\t@Override\n\tString getValue(File file)\n\t{\n\t\treturn file.getName();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/PathExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\n\n/**\n * Matches the path component of a file. Always returns no match because it's difficult to\n * implement, and it's clumsy anyway (it depends on where the \"root\" of the share is).\n */\npublic class PathExpression extends StringExpression\n{\n\tpublic PathExpression(Operator operator, String template, boolean caseSensitive)\n\t{\n\t\tsuper(operator, template, caseSensitive);\n\t}\n\n\t@Override\n\tboolean isEnabled()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"PATH\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"\";\n\t}\n\n\t@Override\n\tString getValue(File file)\n\t{\n\t\treturn \"\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/PopularityExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\n\n/**\n * Matches the popularity of a file. Always returns no match because local files\n * don't have any metadata indicating the popularity.\n * <p>\n * RS does the same.\n */\npublic class PopularityExpression extends RelationalExpression\n{\n\tpublic PopularityExpression(Operator operator, Integer lowerValue, Integer higherValue)\n\t{\n\t\tsuper(operator, lowerValue, higherValue);\n\t}\n\n\t@Override\n\tboolean isEnabled()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"POPULARITY\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"\";\n\t}\n\n\t@Override\n\tint getValue(File file)\n\t{\n\t\treturn 0; // Popularity is not used\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/RelationalExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\nimport java.util.List;\n\nabstract class RelationalExpression implements Expression\n{\n\tpublic enum Operator\n\t{\n\t\tEQUALS, // ==\n\t\tGREATER_THAN_OR_EQUALS, // >=\n\t\tGREATER_THAN, // >\n\t\tLESSER_THAN_OR_EQUALS, // <=\n\t\tLESSER_THAN, // <\n\t\tIN_RANGE\n\t}\n\n\tboolean isEnabled()\n\t{\n\t\treturn true;\n\t}\n\n\tabstract int getValue(File file);\n\n\tabstract String getType();\n\n\t/**\n\t * Gets the column name from the 'FILE' table in the database.\n\t *\n\t * @return the column name in lowercase. Null if the relation must be ignored.\n\t */\n\tabstract String getDatabaseColumnName();\n\n\tprotected final Operator operator;\n\tprotected final int lowerValue;\n\tprotected final int higherValue;\n\n\tprotected RelationalExpression(Operator operator, int lowerValue, int higherValue)\n\t{\n\t\tthis.operator = operator;\n\t\tthis.lowerValue = lowerValue;\n\t\tthis.higherValue = higherValue;\n\t}\n\n\t@Override\n\tpublic boolean evaluate(File file)\n\t{\n\t\tvar value = getValue(file);\n\n\t\t// Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS -> lowerValue == value;\n\t\t\tcase GREATER_THAN_OR_EQUALS -> lowerValue >= value;\n\t\t\tcase GREATER_THAN -> lowerValue > value;\n\t\t\tcase LESSER_THAN_OR_EQUALS -> lowerValue <= value;\n\t\t\tcase LESSER_THAN -> lowerValue < value;\n\t\t\tcase IN_RANGE -> (lowerValue <= value) && (value <= higherValue);\n\t\t};\n\t}\n\n\t@Override\n\tpublic Predicate toPredicate(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\tif (!isEnabled())\n\t\t{\n\t\t\treturn cb.isFalse(cb.literal(true));\n\t\t}\n\n\t\t// Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS -> cb.equal(root.get(getDatabaseColumnName()), lowerValue);\n\t\t\tcase GREATER_THAN_OR_EQUALS -> cb.lessThanOrEqualTo(root.get(getDatabaseColumnName()), lowerValue);\n\t\t\tcase GREATER_THAN -> cb.lessThan(root.get(getDatabaseColumnName()), lowerValue);\n\t\t\tcase LESSER_THAN_OR_EQUALS -> cb.greaterThanOrEqualTo(root.get(getDatabaseColumnName()), lowerValue);\n\t\t\tcase LESSER_THAN -> cb.greaterThan(root.get(getDatabaseColumnName()), lowerValue);\n\t\t\tcase IN_RANGE -> cb.between(root.get(getDatabaseColumnName()), lowerValue, higherValue);\n\t\t};\n\t}\n\n\t@Override\n\tpublic void linearize(List<Byte> tokens, List<Integer> ints, List<String> strings)\n\t{\n\t\ttokens.add(ExpressionType.getTokenValueByClass(getClass()));\n\t\tints.add(operator.ordinal());\n\t\tints.add(lowerValue);\n\t\tints.add(higherValue);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS -> getType() + \" = \" + lowerValue;\n\t\t\tcase GREATER_THAN_OR_EQUALS -> getType() + \" <= \" + lowerValue;\n\t\t\tcase GREATER_THAN -> getType() + \" < \" + lowerValue;\n\t\t\tcase LESSER_THAN_OR_EQUALS -> getType() + \" >= \" + lowerValue;\n\t\t\tcase LESSER_THAN -> getType() + \" > \" + lowerValue;\n\t\t\tcase IN_RANGE -> lowerValue + \" <= \" + getType() + \" <= \" + higherValue;\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/SizeExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\n\n/**\n * Matches the size of the file. Is limited to a maximum file size of a signed 32-bit integer, which is\n * around 2 GB. Use {@link SizeMbExpression} for bigger files.\n */\npublic class SizeExpression extends RelationalExpression\n{\n\tpublic SizeExpression(Operator operator, int lowerValue, int higherValue)\n\t{\n\t\tsuper(operator, lowerValue, higherValue);\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"SIZE\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"size\";\n\t}\n\n\t@Override\n\tint getValue(File file)\n\t{\n\t\treturn Math.clamp(file.getSize(), 0, Integer.MAX_VALUE);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/SizeMbExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\n/**\n * Matches the size of the file. Only works for files bigger than 2 GB. Since it also uses a 32-bit integer, the precision\n * is limited and there's some trickery to make it work but do not expect it to be very precise.\n * <p>\n * The maximum file size is 2.147 TB.\n */\npublic class SizeMbExpression extends RelationalExpression\n{\n\tpublic SizeMbExpression(Operator operator, int lowerValue, int higherValue)\n\t{\n\t\tsuper(operator, lowerValue, higherValue);\n\t}\n\n\t@Override\n\tString getType()\n\t{\n\t\treturn \"SIZE\";\n\t}\n\n\t@Override\n\tString getDatabaseColumnName()\n\t{\n\t\treturn \"size\";\n\t}\n\n\t@Override\n\tpublic Predicate toPredicate(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\tlong lower;\n\t\tlong higher;\n\n\t\t// We need to restore the value with the most pessimistic loss so that the comparison makes sense\n\t\tswitch (operator)\n\t\t{\n\t\t\tcase EQUALS ->\n\t\t\t{\n\t\t\t\tlower = getPessimisticValue(lowerValue);\n\t\t\t\thigher = getOptimisticValue(lowerValue);\n\t\t\t}\n\t\t\tcase GREATER_THAN_OR_EQUALS, GREATER_THAN ->\n\t\t\t{\n\t\t\t\tlower = getOptimisticValue(lowerValue);\n\t\t\t\thigher = getOptimisticValue(lowerValue);\n\t\t\t}\n\t\t\tcase LESSER_THAN_OR_EQUALS, LESSER_THAN ->\n\t\t\t{\n\t\t\t\tlower = getPessimisticValue(lowerValue);\n\t\t\t\thigher = getPessimisticValue(lowerValue);\n\t\t\t}\n\t\t\tcase IN_RANGE ->\n\t\t\t{\n\t\t\t\tlower = getPessimisticValue(lowerValue);\n\t\t\t\thigher = getOptimisticValue(higherValue);\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected operator: \" + operator);\n\t\t}\n\n\t\t// Remember: it's the condition that is checked to be true, i.e. greater than means the expression value is greater than the value of the file\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS, IN_RANGE -> cb.between(root.get(getDatabaseColumnName()), lower, higher);\n\t\t\tcase GREATER_THAN_OR_EQUALS -> cb.lessThanOrEqualTo(root.get(getDatabaseColumnName()), lower);\n\t\t\tcase GREATER_THAN -> cb.lessThan(root.get(getDatabaseColumnName()), lower);\n\t\t\tcase LESSER_THAN_OR_EQUALS -> cb.greaterThanOrEqualTo(root.get(getDatabaseColumnName()), lower);\n\t\t\tcase LESSER_THAN -> cb.greaterThan(root.get(getDatabaseColumnName()), lower);\n\t\t};\n\t}\n\n\tprivate static long getPessimisticValue(int value)\n\t{\n\t\treturn (long) value << 20;\n\t}\n\n\tprivate static long getOptimisticValue(int value)\n\t{\n\t\treturn (long) value << 20 | 0xfffff;\n\t}\n\n\t@Override\n\tint getValue(File file)\n\t{\n\t\treturn (int) (file.getSize() >> 20); // the max value that this check can handle is (2 ^ 31 - 1) * 2 ^ 20, which is 2.147 TB\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/util/expression/StringExpression.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.File;\nimport jakarta.persistence.criteria.CriteriaBuilder;\nimport jakarta.persistence.criteria.Predicate;\nimport jakarta.persistence.criteria.Root;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Locale;\n\npublic abstract class StringExpression implements Expression\n{\n\tpublic enum Operator\n\t{\n\t\tCONTAINS_ANY,\n\t\tCONTAINS_ALL,\n\t\tEQUALS\n\t}\n\n\tboolean isEnabled()\n\t{\n\t\treturn true;\n\t}\n\n\tabstract String getValue(File file);\n\n\tabstract String getType();\n\n\t/**\n\t * Gets the column name from the 'FILE' table in the database.\n\t *\n\t * @return the column name in lowercase. Null if the relation must be ignored.\n\t */\n\tabstract String getDatabaseColumnName();\n\n\tprivate final Operator operator;\n\tprotected final List<String> words;\n\tprivate final boolean caseSensitive;\n\n\tprotected StringExpression(Operator operator, String template, boolean caseSensitive)\n\t{\n\t\tthis.operator = operator;\n\t\tthis.caseSensitive = caseSensitive;\n\t\ttemplate = caseSensitive ? template : template.toLowerCase(Locale.ENGLISH);\n\t\twords = Arrays.stream(template.split(\" \")).toList();\n\t}\n\n\t@Override\n\tpublic boolean evaluate(File file)\n\t{\n\t\tvar value = getValue(file);\n\t\tif (!caseSensitive)\n\t\t{\n\t\t\tvalue = value.toLowerCase(Locale.ENGLISH);\n\t\t}\n\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS -> String.join(\" \", words).equals(value);\n\t\t\tcase CONTAINS_ALL -> words.stream().allMatch(value::contains);\n\t\t\tcase CONTAINS_ANY -> words.stream().anyMatch(value::contains);\n\t\t};\n\t}\n\n\t@Override\n\tpublic Predicate toPredicate(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\tif (!isEnabled())\n\t\t{\n\t\t\treturn cb.isFalse(cb.literal(true));\n\t\t}\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase EQUALS -> equals(cb, root);\n\t\t\tcase CONTAINS_ALL -> contains(cb, root, true);\n\t\t\tcase CONTAINS_ANY -> contains(cb, root, false);\n\t\t};\n\t}\n\n\tprivate Predicate equals(CriteriaBuilder cb, Root<File> root)\n\t{\n\t\tif (caseSensitive)\n\t\t{\n\t\t\treturn cb.equal(root.get(getDatabaseColumnName()), String.join(\" \", words));\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn cb.equal(cb.lower(root.get(getDatabaseColumnName())), String.join(\" \", words).toLowerCase(Locale.ROOT));\n\t\t}\n\t}\n\n\tprivate Predicate contains(CriteriaBuilder cb, Root<File> root, boolean all)\n\t{\n\t\tList<Predicate> predicates = new ArrayList<>();\n\t\twords.forEach(s -> predicates.add(like(cb, root.get(getDatabaseColumnName()), s)));\n\t\tvar array = predicates.toArray(new Predicate[0]);\n\t\treturn all ? cb.and(array) : cb.or(array);\n\t}\n\n\tprotected Predicate like(CriteriaBuilder cb, jakarta.persistence.criteria.Expression<String> x, String pattern)\n\t{\n\t\tif (caseSensitive)\n\t\t{\n\t\t\treturn cb.like(x, \"%\" + pattern + \"%\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn cb.like(cb.lower(x), \"%\" + pattern.toLowerCase(Locale.ROOT) + \"%\");\n\t\t}\n\t}\n\n\t@Override\n\tpublic void linearize(List<Byte> tokens, List<Integer> ints, List<String> strings)\n\t{\n\t\ttokens.add(ExpressionType.getTokenValueByClass(getClass()));\n\t\tints.add(operator.ordinal());\n\t\tints.add(caseSensitive ? 0 : 1);\n\t\tints.add(words.size());\n\t\tstrings.addAll(words);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn switch (operator)\n\t\t{\n\t\t\tcase CONTAINS_ALL -> getType() + \" CONTAINS ALL \" + String.join(\" \", words);\n\t\t\tcase CONTAINS_ANY ->\n\t\t\t{\n\t\t\t\tif (words.size() == 1)\n\t\t\t\t{\n\t\t\t\t\tyield getType() + \" CONTAINS \" + words.getFirst();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tyield getType() + \" CONTAINS ONE OF \" + String.join(\" \", words);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase EQUALS ->\n\t\t\t{\n\t\t\t\tif (words.size() == 1)\n\t\t\t\t{\n\t\t\t\t\tyield getType() + \" IS \" + words.getFirst();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tyield getType() + \" IS ONE OF \" + String.join(\" \", words);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/CommentMessageItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.id.GxsId;\nimport jakarta.persistence.Entity;\n\nimport java.util.Set;\n\n@Entity(name = \"comment_message\")\npublic class CommentMessageItem extends GxsMessageItem\n{\n\tpublic static final int SUBTYPE = 0xf1;\n\n\tprivate String comment;\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn SUBTYPE;\n\t}\n\n\tpublic CommentMessageItem()\n\t{\n\t\t// Needed by JPA\n\t}\n\n\tpublic CommentMessageItem(GxsId gxsId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\tpublic String getComment()\n\t{\n\t\treturn comment;\n\t}\n\n\tpublic void setComment(String comment)\n\t{\n\t\tthis.comment = comment;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\treturn Serializer.serialize(buf, TlvType.STR_GXS_MESSAGE_COMMENT, comment);\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tcomment = (String) Serializer.deserialize(buf, TlvType.STR_GXS_MESSAGE_COMMENT);\n\t}\n\n\t@Override\n\tpublic CommentMessageItem clone()\n\t{\n\t\treturn (CommentMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"CommentMessageItem{\" +\n\t\t\t\t\"comment='\" + comment + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/FileData.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\npublic record FileData(\n\t\tFileItem fileItem,\n\t\tlong offset,\n\t\tbyte[] data\n)\n{\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileData{\" +\n\t\t\t\t\"fileItem=\" + fileItem +\n\t\t\t\t\", offset=\" + offset +\n\t\t\t\t\", data.length=\" + data.length +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/FileItem.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport io.xeres.common.id.Sha1Sum;\nimport jakarta.persistence.AttributeOverride;\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Embeddable;\nimport jakarta.persistence.Embedded;\nimport jakarta.validation.constraints.NotNull;\n\n@Embeddable\npublic record FileItem(\n\t\tlong size,\n\t\t@Embedded\n\t\t@NotNull\n\t\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"hash\"))\n\t\tSha1Sum hash,\n\t\tString name,\n\t\tString path,\n\t\tint age)\n{\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileItem{\" +\n\t\t\t\t\"size=\" + size +\n\t\t\t\t\", hash=\" + hash +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t\", path='\" + path + '\\'' +\n\t\t\t\t\", age=\" + age +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/FileSet.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport java.util.List;\n\npublic record FileSet(\n\t\tList<FileItem> fileItems,\n\t\tString title,\n\t\tString comment\n)\n{\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileSet{\" +\n\t\t\t\t\"fileItems=\" + fileItems +\n\t\t\t\t\", title='\" + title + '\\'' +\n\t\t\t\t\", comment='\" + comment + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/SecurityKey.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport io.xeres.common.id.GxsId;\nimport jakarta.persistence.AttributeOverride;\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Embeddable;\nimport jakarta.persistence.Embedded;\nimport jakarta.validation.constraints.NotNull;\n\nimport java.math.BigInteger;\nimport java.time.Instant;\nimport java.util.Arrays;\nimport java.util.EnumSet;\nimport java.util.Objects;\nimport java.util.Set;\n\n@Embeddable\npublic final class SecurityKey implements Comparable<SecurityKey>\n{\n\t@Embedded\n\t@NotNull\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"key_id\"))\n\tprivate GxsId keyGxsId;\n\n\tprivate Set<Flags> flags = EnumSet.noneOf(SecurityKey.Flags.class);\n\n\t@NotNull\n\tprivate Instant validFrom;\n\n\tprivate Instant validTo; // if null, there's no expiration\n\n\tprivate byte[] data;\n\n\tpublic SecurityKey()\n\t{\n\t}\n\n\tpublic SecurityKey(@NotNull GxsId keyGxsId, Set<Flags> flags, @NotNull Instant validFrom, Instant validTo, byte[] data)\n\t{\n\t\tthis.keyGxsId = keyGxsId;\n\t\tthis.flags = flags;\n\t\tthis.validFrom = validFrom;\n\t\tthis.validTo = validTo;\n\t\tthis.data = data;\n\t}\n\n\tpublic SecurityKey(@NotNull GxsId keyGxsId, Set<Flags> flags, int validFrom, int validTo, byte[] data)\n\t{\n\t\tthis.keyGxsId = keyGxsId;\n\t\tthis.flags = flags;\n\t\tthis.validFrom = Instant.ofEpochSecond(validFrom);\n\t\tthis.validTo = validTo == 0 ? null : Instant.ofEpochSecond(validTo);\n\t\tthis.data = data;\n\t}\n\n\tpublic @NotNull GxsId getKeyGxsId()\n\t{\n\t\treturn keyGxsId;\n\t}\n\n\tpublic void setKeyGxsId(@NotNull GxsId keyId)\n\t{\n\t\tthis.keyGxsId = keyId;\n\t}\n\n\tpublic Set<Flags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\tpublic void setFlags(Set<Flags> flags)\n\t{\n\t\tthis.flags = flags;\n\t}\n\n\tpublic @NotNull Instant getValidFrom()\n\t{\n\t\treturn validFrom;\n\t}\n\n\tpublic void setValidFrom(@NotNull Instant validFrom)\n\t{\n\t\tthis.validFrom = validFrom;\n\t}\n\n\tpublic int getValidFromInTs()\n\t{\n\t\treturn (int) validFrom.getEpochSecond();\n\t}\n\n\tpublic void setValidFrom(int validFrom)\n\t{\n\t\tthis.validFrom = Instant.ofEpochSecond(validFrom);\n\t}\n\n\tpublic Instant getValidTo()\n\t{\n\t\treturn validTo;\n\t}\n\n\tpublic void setValidTo(Instant validTo)\n\t{\n\t\tthis.validTo = validTo;\n\t}\n\n\tpublic int getValidToInTs()\n\t{\n\t\tif (validTo == null)\n\t\t{\n\t\t\treturn 0; // no expiration\n\t\t}\n\t\treturn (int) validTo.getEpochSecond();\n\t}\n\n\tpublic void setValidTo(int validTo)\n\t{\n\t\tif (validTo == 0)\n\t\t{\n\t\t\tthis.validTo = null;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.validTo = Instant.ofEpochSecond(validTo);\n\t\t}\n\t}\n\n\tpublic byte[] getData()\n\t{\n\t\treturn data;\n\t}\n\n\tpublic void setData(byte[] data)\n\t{\n\t\tthis.data = data;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object obj)\n\t{\n\t\tif (obj == this)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (obj == null || obj.getClass() != getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (SecurityKey) obj;\n\t\treturn Objects.equals(keyGxsId, that.keyGxsId) && Objects.deepEquals(data, that.data);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(keyGxsId, Arrays.hashCode(data));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"SecurityKey[\" +\n\t\t\t\t\"gxsId=\" + keyGxsId + \", \" +\n\t\t\t\t\"flags=\" + flags + \", \" +\n\t\t\t\t\"validFrom=\" + validFrom + \", \" +\n\t\t\t\t\"validTo=\" + validTo;\n\t}\n\n\t@Override\n\tpublic int compareTo(SecurityKey other)\n\t{\n\t\t// This really is the sorting order for Retroshare...\n\t\treturn new BigInteger(1, keyGxsId.getBytes()).compareTo(new BigInteger(1, other.getKeyGxsId().getBytes()));\n\t}\n\n\tpublic enum Flags\n\t{\n\t\tTYPE_PUBLIC_ONLY, // 0x1\n\t\tTYPE_FULL, // 0x2\n\t\tUNUSED_3, // 0x4\n\t\tUNUSED_4, // 0x8\n\t\tUNUSED_5, // 0x10\n\t\tDISTRIBUTION_PUBLISHING, // 0x20\n\t\tDISTRIBUTION_ADMIN, // 0x40\n\t\tUNUSED_8; // 0x80\n\n\t\tpublic static Set<Flags> ofTypes()\n\t\t{\n\t\t\treturn EnumSet.of(TYPE_PUBLIC_ONLY, TYPE_FULL);\n\t\t}\n\n\t\tpublic static Set<Flags> ofDistributions()\n\t\t{\n\t\t\treturn EnumSet.of(DISTRIBUTION_PUBLISHING, DISTRIBUTION_ADMIN);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/Signature.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport io.xeres.common.id.GxsId;\nimport jakarta.persistence.AttributeOverride;\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Embeddable;\nimport jakarta.persistence.Embedded;\nimport jakarta.validation.constraints.NotNull;\n\nimport java.util.Arrays;\nimport java.util.Objects;\n\n@Embeddable\npublic final class Signature implements Comparable<Signature>\n{\n\tprivate Type type;\n\n\t@Embedded\n\t@NotNull\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"gxs_id\"))\n\tprivate GxsId gxsId;\n\n\tprivate byte[] data;\n\n\tpublic Signature()\n\t{\n\t}\n\n\tpublic Signature(Type type, @NotNull GxsId gxsId, byte[] data)\n\t{\n\t\tthis.type = type;\n\t\tthis.gxsId = gxsId;\n\t\tthis.data = data;\n\t}\n\n\tpublic Signature(@NotNull GxsId gxsId, byte[] data)\n\t{\n\t\tthis.gxsId = gxsId;\n\t\tthis.data = data;\n\t}\n\n\tpublic Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tpublic @NotNull GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(@NotNull GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic byte[] getData()\n\t{\n\t\treturn data;\n\t}\n\n\tpublic void setData(byte[] data)\n\t{\n\t\tthis.data = data;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar signature = (Signature) o;\n\t\treturn type == signature.type && Objects.equals(gxsId, signature.gxsId) && Objects.deepEquals(data, signature.data);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(type, gxsId, Arrays.hashCode(data));\n\t}\n\n\t@Override\n\tpublic int compareTo(Signature o)\n\t{\n\t\treturn type.getValue() - o.type.getValue();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Signature{\" +\n\t\t\t\t\"gxsId=\" + gxsId +\n\t\t\t\t'}';\n\t}\n\n\tpublic enum Type\n\t{\n\t\tAUTHOR(0x10), // RS calls it IDENTITY\n\t\tPUBLISH(0x20),\n\t\tADMIN(0x40);\n\n\t\tType(int value)\n\t\t{\n\t\t\tthis.value = value;\n\t\t}\n\n\t\tprivate final int value;\n\n\t\tpublic int getValue()\n\t\t{\n\t\t\treturn value;\n\t\t}\n\n\t\tpublic static Signature.Type findByValue(int value)\n\t\t{\n\t\t\treturn Arrays.stream(values()).filter(type -> type.getValue() == value).findFirst().orElseThrow();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/common/VoteMessageItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.GxsId;\nimport jakarta.persistence.Entity;\n\nimport java.util.Set;\n\n@Entity(name = \"vote_message\")\npublic class VoteMessageItem extends GxsMessageItem\n{\n\tpublic enum Type\n\t{\n\t\t/**\n\t\t * Unset vote?\n\t\t */\n\t\tNONE,\n\t\t/**\n\t\t * Negative vote.\n\t\t */\n\t\tDOWN,\n\t\t/**\n\t\t * Positive vote.\n\t\t */\n\t\tUP\n\t}\n\n\tpublic static final int SUBTYPE = 0xf2;\n\n\tprivate Type type;\n\n\tpublic VoteMessageItem()\n\t{\n\t\t// Needed by JPA\n\t}\n\n\tpublic VoteMessageItem(GxsId gxsId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn SUBTYPE;\n\t}\n\n\tpublic Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\treturn Serializer.serialize(buf, type);\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\ttype = Serializer.deserializeEnum(buf, Type.class);\n\t}\n\n\t@Override\n\tpublic VoteMessageItem clone()\n\t{\n\t\treturn (VoteMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VoteMessageItem{\" +\n\t\t\t\t\"type=\" + type +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/item/Item.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.buffer.ByteBufAllocator;\nimport io.netty.util.ReferenceCountUtil;\nimport io.xeres.app.database.model.gxs.GxsMetaAndData;\nimport io.xeres.app.xrs.serialization.GxsMetaAndDataResult;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.gxs.item.DynamicServiceType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.Objects;\nimport java.util.Set;\n\nimport static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE;\n\n/**\n * An item is the base class for the transmission of data within the RS protocol.\n * They have a service type and a subtype within that service.\n */\npublic abstract class Item implements Cloneable\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Item.class);\n\n\tprivate static final int VERSION = 2;\n\n\tprotected ByteBuf buf;\n\tprivate ByteBuf backupBuf;\n\n\tpublic abstract int getServiceType(); // returns an int so that it's easier to externalize plugins later on\n\n\tpublic abstract int getSubType();\n\n\tprotected Item()\n\t{\n\t\t// Needed for instantiation\n\t}\n\n\tpublic void setIncoming(ByteBuf buf)\n\t{\n\t\tthis.buf = buf;\n\t}\n\n\tpublic void setOutgoing(ByteBufAllocator allocator, RsService service)\n\t{\n\t\tbuf = allocator.buffer();\n\t\tbuf.writeByte(VERSION);\n\n\t\t// Handle items that are shared between service and hence have no intrinsic service type\n\t\tif (DynamicServiceType.class.isAssignableFrom(getClass()))\n\t\t{\n\t\t\t((DynamicServiceType) this).setServiceType(Objects.requireNonNull(service, \"Service cannot be null for a DynamicServiceType\").getServiceType().getType());\n\t\t}\n\t\tbuf.writeShort(getServiceType());\n\t\tbuf.writeByte(getSubType());\n\t\tbuf.writeInt(HEADER_SIZE);\n\t}\n\n\tpublic void setSerialization(ByteBufAllocator allocator, RsService service)\n\t{\n\t\tbackupBuf = buf;\n\t\tsetOutgoing(allocator, service);\n\t}\n\n\tpublic RawItem serializeItem(Set<SerializationFlags> flags)\n\t{\n\t\tvar size = 0;\n\n\t\tif (GxsMetaAndData.class.isAssignableFrom(getClass()))\n\t\t{\n\t\t\tlog.trace(\"Serializing class {} using GxsGroupItem system, flags: {}\", getClass().getSimpleName(), flags);\n\t\t\tvar result = new GxsMetaAndDataResult();\n\t\t\tsize += Serializer.serializeGxsMetaAndDataItem(buf, (GxsMetaAndData) this, flags, result);\n\n\t\t\t// RS sets this as the size for GxsMetaAndData\n\t\t\tsetItemSize(result.getDataSize() + HEADER_SIZE);\n\t\t}\n\t\telse if (RsSerializable.class.isAssignableFrom(getClass()))\n\t\t{\n\t\t\tlog.trace(\"Serializing class {} using writeObject(), flags: {}\", getClass().getSimpleName(), flags);\n\t\t\tsize += Serializer.serializeRsSerializable(buf, (RsSerializable) this, flags);\n\t\t\tsetItemSize(size + HEADER_SIZE);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.trace(\"Serializing class {} using annotations\", getClass().getSimpleName());\n\t\t\tsize += Serializer.serializeAnnotatedFields(buf, this);\n\t\t\tsetItemSize(size + HEADER_SIZE);\n\t\t}\n\t\tlog.debug(\"==> {} ({})\", getClass().getSimpleName(), size + HEADER_SIZE);\n\n\t\tvar rawItem = new RawItem(buf, getPriority());\n\t\tlog.trace(\"Serialized buffer ==> {}\", rawItem);\n\t\tif (flags.contains(SerializationFlags.SIGNATURE) || flags.contains(SerializationFlags.SIZE))\n\t\t{\n\t\t\tbuf = backupBuf;\n\t\t\tbackupBuf = null;\n\t\t}\n\t\treturn rawItem;\n\t}\n\n\tpublic int getPriority() // returns an int so that it's easier to externalize plugins later on\n\t{\n\t\treturn ItemPriority.DEFAULT.getPriority();\n\t}\n\n\tpublic void dispose()\n\t{\n\t\tif (buf != null)\n\t\t{\n\t\t\tassert buf.refCnt() == 1 : \"buffer refCount is \" + buf.refCnt();\n\t\t\tReferenceCountUtil.release(buf);\n\t\t\tbuf = null;\n\t\t}\n\t}\n\n\t/**\n\t * Get the item's serialized size. This is always set for incoming items from their deserialization until they're disposed. For outgoing items, the value is undefined until the\n\t * item has been serialized.\n\t *\n\t * @return the size of the item in its serialized form\n\t */\n\tpublic int getItemSize()\n\t{\n\t\treturn buf.getInt(4);\n\t}\n\n\tprotected void setItemSize(int size)\n\t{\n\t\tbuf.setInt(4, size);\n\t}\n\n\t/**\n\t * To clone an item's subclass. Override the clone() method so that it returns the right type (so that calling clone()\n\t * on the subclass, will not return the superclass' type, which is {@link #Item}). There's no need to implement the {@link Cloneable} method in the subclass and\n\t * there's no need to deep copy any field either as the only use of clone() in an item's subclass is for sending it to multiple recipient\n\t * and nothing will modify any data (mutable or not) data.\n\t *\n\t * @return an Item's clone\n\t */\n\t@Override\n\tpublic Item clone()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar clone = (Item) super.clone();\n\t\t\tclone.buf = null;\n\t\t\treturn clone;\n\t\t}\n\t\tcatch (CloneNotSupportedException _)\n\t\t{\n\t\t\tthrow new AssertionError();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/item/ItemHeader.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.Serializer;\n\npublic class ItemHeader\n{\n\tprivate final ByteBuf buf;\n\tprivate final int serviceType;\n\tprivate final int subType;\n\tprivate int size;\n\tprivate int sizeOffset;\n\n\tpublic ItemHeader(ByteBuf buf, int serviceType, int subType)\n\t{\n\t\tthis.buf = buf;\n\t\tthis.serviceType = serviceType;\n\t\tthis.subType = subType;\n\t}\n\n\tpublic int writeHeader()\n\t{\n\t\tsize = Serializer.serialize(buf, (byte) 2);\n\t\tsize += Serializer.serialize(buf, (short) serviceType);\n\t\tsize += Serializer.serialize(buf, (byte) subType);\n\t\tsizeOffset = buf.writerIndex();\n\t\tsize += Serializer.serialize(buf, 0); // the size is written at the end when calling writeSize()\n\t\treturn size;\n\t}\n\n\tpublic int writeSize(int dataSize)\n\t{\n\t\tsize += dataSize;\n\t\tbuf.setInt(sizeOffset, size);\n\t\treturn size;\n\t}\n\n\tpublic static void readHeader(ByteBuf buf, int serviceType, int subType)\n\t{\n\t\tif (buf.readByte() != 2)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Packet version is not 0x2\");\n\t\t}\n\t\tif (buf.readUnsignedShort() != serviceType)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Packet type is not \" + serviceType);\n\t\t}\n\t\tif (buf.readUnsignedByte() != subType)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Packet subtype is not \" + subType);\n\t\t}\n\t\tbuf.readInt(); // size\n\t}\n\n\tpublic static int getSubType(ByteBuf buf)\n\t{\n\t\treturn buf.getUnsignedByte(3);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/item/ItemPriority.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\npublic enum ItemPriority\n{\n\t/**\n\t * Anything that happens in the background and is not really urgent (for example discovery exchanges, file transfers, ...)\n\t */\n\tBACKGROUND(2),\n\n\t/**\n\t * The default priority.\n\t */\n\tDEFAULT(3),\n\n\tNORMAL(5),\n\n\t/**\n\t * High priority. Has consequences for other services and should be serviced quickly (for example GxS exchanges).\n\t */\n\tHIGH(6),\n\n\t/**\n\t * Generated by a  user and requires immediate feedback (for example chat, typing feedback, ...)\n\t */\n\tINTERACTIVE(7),\n\n\t/**\n\t * Must be acknowledged by the other peer quickly, or it will have disruptive effects (for example, heartbeats).\n\t */\n\tIMPORTANT(8),\n\n\t/**\n\t * Must be carried away immediately, or it won't be usable (for example RTT measurements).\n\t */\n\tREALTIME(9);\n\n\tprivate final int priority;\n\n\tItemPriority(int priority)\n\t{\n\t\tthis.priority = priority;\n\t}\n\n\tpublic int getPriority()\n\t{\n\t\treturn priority;\n\t}\n}\n\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/item/ItemUtils.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\n\nimport java.util.EnumSet;\n\npublic final class ItemUtils\n{\n\tprivate ItemUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Serializes an item to get its serialized size.\n\t * <p>\n\t * Note: prefer {@link Item#getItemSize()} which is set for incoming items. For outgoing items, if you don't\n\t * need the size before writing the item, prefer {@link PeerConnectionManager#writeItem(PeerConnection, Item, RsService)}.\n\t *\n\t * @param item    the item\n\t * @param service the service\n\t * @return the total serialized size in bytes\n\t */\n\tpublic static int getItemSerializedSize(Item item, RsService service)\n\t{\n\t\titem.setSerialization(Unpooled.buffer().alloc(), service);\n\t\tvar rawItem = item.serializeItem(EnumSet.of(SerializationFlags.SIZE));\n\t\tvar size = rawItem.getSize();\n\t\trawItem.getBuffer().release();\n\t\treturn size;\n\t}\n\n\t/**\n\t * Serializes an item to make a signature out of it.\n\t *\n\t * @param item    the item\n\t * @param service the service\n\t * @return a byte array\n\t */\n\tpublic static byte[] serializeItemForSignature(Item item, RsService service)\n\t{\n\t\titem.setSerialization(Unpooled.buffer().alloc(), service);\n\t\tvar buf = item.serializeItem(EnumSet.of(SerializationFlags.SIGNATURE)).getBuffer();\n\t\tvar data = new byte[buf.writerIndex()];\n\t\tbuf.getBytes(0, data);\n\t\tbuf.release();\n\t\treturn data;\n\t}\n\n\t/**\n\t * Serializes an item. Do not use this within a netty pipeline.\n\t *\n\t * @param item    the item\n\t * @param service the service\n\t * @return a byte array\n\t */\n\tpublic static byte[] serializeItem(Item item, RsService service)\n\t{\n\t\titem.setSerialization(Unpooled.buffer().alloc(), service);\n\t\tvar buf = item.serializeItem(EnumSet.noneOf(SerializationFlags.class)).getBuffer();\n\t\tvar data = new byte[buf.writerIndex()];\n\t\tbuf.getBytes(0, data);\n\t\tbuf.release();\n\t\treturn data;\n\t}\n\n\t/**\n\t * Deserializes an item. Do not use this within a netty pipeline.\n\t *\n\t * @param data     the byte array of the item\n\t * @param registry the registry to build the item\n\t * @return the item, not null\n\t */\n\tpublic static Item deserializeItem(byte[] data, RsServiceRegistry registry)\n\t{\n\t\tvar rawItem = new RawItem(Unpooled.wrappedBuffer(data), ItemPriority.DEFAULT.getPriority());\n\t\tvar item = registry.buildIncomingItem(rawItem);\n\t\trawItem.deserialize(item);\n\t\trawItem.dispose();\n\t\treturn item;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/item/RawItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.util.ReferenceCountUtil;\nimport io.xeres.app.database.model.gxs.GxsMetaAndData;\nimport io.xeres.app.net.peer.packet.Packet;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.app.xrs.service.DefaultItem;\nimport org.bouncycastle.util.encoders.Hex;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE;\n\npublic class RawItem\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RawItem.class);\n\n\tprivate int priority = ItemPriority.DEFAULT.getPriority();\n\tprotected ByteBuf buf;\n\n\tpublic RawItem()\n\t{\n\t}\n\n\tpublic RawItem(Packet packet)\n\t{\n\t\tpriority = packet.getPriority();\n\t\tbuf = packet.getItemBuffer();\n\t}\n\n\tpublic RawItem(ByteBuf buf, int priority)\n\t{\n\t\tthis.buf = buf;\n\t\tthis.priority = priority;\n\t}\n\n\tpublic void deserialize(Item item)\n\t{\n\t\titem.setIncoming(buf);\n\n\t\tbuf.skipBytes(HEADER_SIZE);\n\n\t\tif (item instanceof DefaultItem)\n\t\t{\n\t\t\tbuf.skipBytes(getItemSize());\n\t\t}\n\t\telse if (GxsMetaAndData.class.isAssignableFrom(item.getClass()))\n\t\t{\n\t\t\t// This cannot be deserialized because the data is before the metadata, and the data can vary in length (optional fields at the end). It would only be possible if the data was last.\n\t\t\tthrow new IllegalArgumentException(\"Cannot deserialize a GxsMetaAndData item\");\n\t\t}\n\t\telse if (RsSerializable.class.isAssignableFrom(item.getClass()))\n\t\t{\n\t\t\t// If the object implements RsSerializable, which is more flexible, use it\n\t\t\tlog.trace(\"Deserializing class {} using readObject()\", item.getClass().getSimpleName());\n\t\t\tSerializer.deserializeRsSerializable(buf, (RsSerializable) item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Otherwise, use the more convenient @RsSerialized notations (recommended)\n\t\t\tlog.trace(\"Deserializing class {} using annotations\", item.getClass().getSimpleName());\n\t\t\tSerializer.deserializeAnnotatedFields(buf, item);\n\t\t}\n\n\t\t// Check if the size matches\n\t\tif (buf.readerIndex() != getItemSize())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Size mismatch, size in header: \" + getItemSize() + \", actual read size: \" + buf.readerIndex() + \", (Version: \" + getPacketVersion() + \", Service: \" + getPacketService() + \", SubType: \" + getPacketSubType() + \")\");\n\t\t}\n\t}\n\n\tpublic int getPacketVersion()\n\t{\n\t\treturn buf.getUnsignedByte(0);\n\t}\n\n\tpublic int getPacketService()\n\t{\n\t\treturn buf.getUnsignedShort(1);\n\t}\n\n\tpublic int getPacketSubType()\n\t{\n\t\treturn buf.getUnsignedByte(3);\n\t}\n\n\tprivate int getItemSize()\n\t{\n\t\treturn buf.getInt(4);\n\t}\n\n\tpublic int getSize()\n\t{\n\t\treturn getItemSize() + HEADER_SIZE;\n\t}\n\n\tpublic ByteBuf getBuffer()\n\t{\n\t\treturn buf;\n\t}\n\n\tpublic int getPriority()\n\t{\n\t\treturn priority;\n\t}\n\n\tpublic void dispose()\n\t{\n\t\tReferenceCountUtil.release(buf);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\tString bufOut = null;\n\t\tvar size = 0;\n\t\tif (buf != null)\n\t\t{\n\t\t\tbuf.markReaderIndex();\n\t\t\tbuf.readerIndex(0);\n\t\t\tvar out = new byte[buf.writerIndex()];\n\t\t\tsize = buf.writerIndex();\n\t\t\tbuf.readBytes(out);\n\t\t\tbuf.resetReaderIndex();\n\t\t\tbufOut = new String(Hex.encode(out));\n\t\t}\n\n\t\treturn \"RawItem{\" +\n\t\t\t\t\"priority=\" + priority +\n\t\t\t\t\", buf=\" + bufOut +\n\t\t\t\t\", size=\" + size +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/AnnotationSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.item.Item;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.Field;\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nfinal class AnnotationSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(AnnotationSerializer.class);\n\n\tprivate AnnotationSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Object object)\n\t{\n\t\tvar size = 0;\n\n\t\tfor (var field : getAllFields(object.getClass(), isClassOrderReversed(object)))\n\t\t{\n\t\t\tlog.trace(\"Serializing field {}, of type {}\", field.getName(), field.getType().getSimpleName());\n\t\t\tsize += Serializer.serialize(buf, field, object);\n\t\t}\n\t\treturn size;\n\t}\n\n\tstatic Object deserializeForClass(ByteBuf buf, Class<?> javaClass)\n\t{\n\t\tObject instanceObject;\n\t\ttry\n\t\t{\n\t\t\tinstanceObject = javaClass.getDeclaredConstructor().newInstance();\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot instantiate object of class \" + javaClass.getSimpleName());\n\t\t}\n\t\tif (!deserialize(buf, instanceObject))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot deserialize object of class \" + javaClass.getSimpleName());\n\t\t}\n\t\treturn instanceObject;\n\t}\n\n\tstatic boolean deserialize(ByteBuf buf, Object object)\n\t{\n\t\tvar allFields = getAllFields(object.getClass(), isClassOrderReversed(object));\n\n\t\tfor (var field : allFields)\n\t\t{\n\t\t\tlog.trace(\"Deserializing field {}, of type {}\", field.getName(), field.getType().getSimpleName());\n\t\t\tSerializer.deserialize(buf, field, object, field.getAnnotation(RsSerialized.class));\n\t\t}\n\t\treturn !allFields.isEmpty();\n\t}\n\n\t/**\n\t * Search all fields annotated with @RsSerialized, starting with the\n\t * first subclass of Item down to the last subclass.<br>\n\t *\n\t * @param javaClass the class\n\t * @return all fields ordered from superclass to subclass\n\t */\n\tprivate static List<Field> getAllFields(Class<?> javaClass, boolean reversed)\n\t{\n\t\tif (javaClass == null || javaClass == Item.class)\n\t\t{\n\t\t\treturn Collections.emptyList();\n\t\t}\n\n\t\tList<Field> superFields = new ArrayList<>(getAllFields(javaClass.getSuperclass(), reversed));\n\t\tvar classFields = Arrays.stream(javaClass.getDeclaredFields())\n\t\t\t\t.filter(field -> {\n\t\t\t\t\tfield.setAccessible(true); // NOSONAR\n\t\t\t\t\treturn field.isAnnotationPresent(RsSerialized.class);\n\t\t\t\t})\n\t\t\t\t.collect(Collectors.toCollection(ArrayList::new));\n\n\t\tif (reversed)\n\t\t{\n\t\t\tclassFields.addAll(superFields);\n\t\t\treturn classFields;\n\t\t}\n\t\tsuperFields.addAll(classFields);\n\t\treturn superFields;\n\t}\n\n\tprivate static boolean isClassOrderReversed(Object object)\n\t{\n\t\treturn object.getClass().getDeclaredAnnotation(RsClassSerializedReversed.class) != null;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/ArraySerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\n\nfinal class ArraySerializer\n{\n\tprivate ArraySerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Class<?> javaClass, Object object)\n\t{\n\t\tif (javaClass.equals(byte[].class))\n\t\t{\n\t\t\treturn ByteArraySerializer.serialize(buf, (byte[]) object);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unhandled array type \" + javaClass.getSimpleName()); // XXX: handle other types (see what RS uses...)\n\t\t}\n\t}\n\n\tstatic Object deserialize(ByteBuf buf, Class<?> javaClass)\n\t{\n\t\tif (javaClass.equals(byte[].class))\n\t\t{\n\t\t\treturn ByteArraySerializer.deserialize(buf);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unhandled array type \" + javaClass.getSimpleName()); // XXX\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/BigIntegerSerializer.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.bouncycastle.util.BigIntegers;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.math.BigInteger;\n\nfinal class BigIntegerSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(BigIntegerSerializer.class);\n\n\tprivate BigIntegerSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, BigInteger value)\n\t{\n\t\tlog.trace(\"Writing big integer: {}\", value);\n\t\tvar data = BigIntegers.asUnsignedByteArray(value);\n\t\tbuf.ensureWritable(Integer.BYTES + data.length);\n\t\tbuf.writeInt(data.length);\n\t\tbuf.writeBytes(data);\n\t\treturn Integer.BYTES + data.length;\n\t}\n\n\tstatic BigInteger deserialize(ByteBuf buf)\n\t{\n\t\tvar len = buf.readInt();\n\t\tlog.trace(\"Reading big integer of size: {}\", len);\n\t\tvar out = new byte[len];\n\t\tbuf.readBytes(out);\n\t\treturn BigIntegers.fromUnsignedByteArray(out);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/BooleanSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class BooleanSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(BooleanSerializer.class);\n\n\tprivate BooleanSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, boolean value)\n\t{\n\t\tlog.trace(\"Writing boolean: {}\", value);\n\t\tbuf.ensureWritable(1);\n\t\tbuf.writeBoolean(value);\n\t\treturn 1;\n\t}\n\n\tstatic boolean deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readBoolean();\n\t\tlog.trace(\"Reading boolean: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/ByteArraySerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class ByteArraySerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ByteArraySerializer.class);\n\n\tprivate ByteArraySerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, byte[] a)\n\t{\n\t\tif (a == null)\n\t\t{\n\t\t\tbuf.ensureWritable(Integer.BYTES);\n\t\t\tbuf.writeInt(0);\n\t\t\treturn Integer.BYTES;\n\t\t}\n\t\tlog.trace(\"Writing byte array of size {}\", a.length);\n\t\tbuf.ensureWritable(Integer.BYTES + a.length);\n\t\tbuf.writeInt(a.length);\n\t\tbuf.writeBytes(a);\n\t\treturn Integer.BYTES + a.length;\n\t}\n\n\tstatic byte[] deserialize(ByteBuf buf)\n\t{\n\t\tvar len = buf.readInt();\n\t\tlog.trace(\"Reading byte array of size {}\", len);\n\t\tvar out = new byte[len];\n\t\tbuf.readBytes(out);\n\t\treturn out;\n\t}\n\n\tstatic int serialize(ByteBuf buf, byte[] array, int size)\n\t{\n\t\tlog.trace(\"Writing byte array of specific size {}\", size);\n\t\tbuf.ensureWritable(size);\n\t\tbuf.writeBytes(array, 0, size);\n\t\treturn size;\n\t}\n\n\tstatic byte[] deserialize(ByteBuf buf, int size)\n\t{\n\t\tlog.trace(\"Reading byte array of specific size {}\", size);\n\t\tvar out = new byte[size];\n\t\tbuf.readBytes(out);\n\t\treturn out;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/ByteSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class ByteSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ByteSerializer.class);\n\n\tprivate ByteSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, byte value)\n\t{\n\t\tlog.trace(\"Writing byte: {}\", value);\n\t\tbuf.ensureWritable(Byte.BYTES);\n\t\tbuf.writeByte(value);\n\t\treturn Byte.BYTES;\n\t}\n\n\tstatic byte deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readByte();\n\t\tlog.trace(\"Reading byte: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/DoubleSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class DoubleSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DoubleSerializer.class);\n\n\tprivate DoubleSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, double value)\n\t{\n\t\tlog.trace(\"Writing double: {}\", value);\n\t\tbuf.ensureWritable(Double.BYTES);\n\t\tbuf.writeDouble(value);\n\t\treturn Double.BYTES;\n\t}\n\n\tstatic double deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readDouble();\n\t\tlog.debug(\"Reading double: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/EnumSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.Objects;\n\nfinal class EnumSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(EnumSerializer.class);\n\n\tprivate EnumSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, Enum<? extends Enum<?>> e)\n\t{\n\t\tObjects.requireNonNull(e, \"Null enum not supported\");\n\t\tlog.trace(\"Writing enum ordinal value: {}\", e.ordinal());\n\t\tbuf.ensureWritable(Integer.BYTES);\n\t\tbuf.writeInt(e.ordinal());\n\t\treturn Integer.BYTES;\n\t}\n\n\tstatic int getSize()\n\t{\n\t\treturn Integer.BYTES;\n\t}\n\n\t@SuppressWarnings(\"unchecked\")\n\tstatic <E extends Enum<E>> E deserialize(ByteBuf buf, Class<?> e)\n\t{\n\t\tvar val = buf.readInt();\n\t\tlog.trace(\"Reading enum ordinal value: {}, class: {}\", val, e.getSimpleName());\n\t\treturn (E) e.getEnumConstants()[val];\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/EnumSetSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.ParameterizedType;\nimport java.util.EnumSet;\nimport java.util.Objects;\nimport java.util.Set;\n\nfinal class EnumSetSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(EnumSetSerializer.class);\n\n\tprivate EnumSetSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Set<? extends Enum<?>> enumSet, RsSerialized annotation)\n\t{\n\t\tObjects.requireNonNull(annotation, \"Annotation is needed for EnumSet\");\n\t\tvar fieldSize = annotation.fieldSize();\n\n\t\treturn serialize(buf, enumSet, fieldSize);\n\t}\n\n\tstatic int serialize(ByteBuf buf, Set<? extends Enum<?>> enumSet, FieldSize fieldSize)\n\t{\n\t\tObjects.requireNonNull(enumSet, \"Null enumset not supported\");\n\t\treturn switch (fieldSize)\n\t\t\t\t{\n\t\t\t\t\tcase INTEGER -> serializeEnumSetInt(buf, enumSet);\n\t\t\t\t\tcase BYTE -> serializeEnumSetByte(buf, enumSet);\n\t\t\t\t\tcase SHORT -> serializeEnumSetShort(buf, enumSet);\n\t\t\t\t};\n\t}\n\n\tprivate static int serializeEnumSetInt(ByteBuf buf, Set<? extends Enum<?>> enumSet)\n\t{\n\t\tif (enumSet.size() > Integer.SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"EnumSet cannot have more than \" + Integer.SIZE + \" entries\");\n\t\t}\n\t\tvar size = Integer.BYTES;\n\n\t\tlog.trace(\"Enumset (int): {}\", enumSet);\n\t\tbuf.ensureWritable(size);\n\t\tvar value = 0;\n\t\tfor (Enum<?> anEnum : enumSet)\n\t\t{\n\t\t\tvalue |= 1 << anEnum.ordinal();\n\t\t}\n\t\tbuf.writeInt(value);\n\t\treturn size;\n\t}\n\n\tprivate static int serializeEnumSetByte(ByteBuf buf, Set<? extends Enum<?>> enumSet)\n\t{\n\t\tif (enumSet.size() > Byte.SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"EnumSet for a byte cannot have more than \" + Byte.SIZE + \" entries\");\n\t\t}\n\t\tvar size = Byte.BYTES;\n\n\t\tlog.trace(\"Enumset (byte): {}\", enumSet);\n\t\tbuf.ensureWritable(size);\n\t\tbyte value = 0;\n\t\tfor (Enum<?> anEnum : enumSet)\n\t\t{\n\t\t\tvalue |= (byte) (1 << anEnum.ordinal());\n\t\t}\n\t\tbuf.writeByte(value);\n\t\treturn size;\n\t}\n\n\tprivate static int serializeEnumSetShort(ByteBuf buf, Set<? extends Enum<?>> enumSet)\n\t{\n\t\tif (enumSet.size() > Short.SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"EnumSet for a short cannot have more than \" + Short.SIZE + \" entries\");\n\t\t}\n\t\tvar size = Short.BYTES;\n\n\t\tlog.trace(\"Enumset (short): {}\", enumSet);\n\t\tbuf.ensureWritable(size);\n\t\tshort value = 0;\n\t\tfor (Enum<?> anEnum : enumSet)\n\t\t{\n\t\t\tvalue |= (short) (1 << anEnum.ordinal());\n\t\t}\n\t\tbuf.writeShort(value);\n\t\treturn size;\n\t}\n\n\tstatic <E extends Enum<E>> Set<E> deserialize(ByteBuf buf, ParameterizedType type, RsSerialized annotation)\n\t{\n\t\tObjects.requireNonNull(annotation, \"Annotation is needed for EnumSet\");\n\t\t@SuppressWarnings(\"unchecked\")\n\t\tvar enumClass = (Class<E>) type.getActualTypeArguments()[0];\n\n\t\tvar fieldSize = annotation.fieldSize();\n\n\t\treturn deserialize(buf, enumClass, fieldSize);\n\t}\n\n\tstatic <E extends Enum<E>> Set<E> deserialize(ByteBuf buf, Class<E> e, FieldSize fieldSize)\n\t{\n\t\treturn switch (fieldSize)\n\t\t\t\t{\n\t\t\t\t\tcase INTEGER -> deserializeEnumSetInt(buf, e);\n\t\t\t\t\tcase BYTE -> deserializeEnumSetByte(buf, e);\n\t\t\t\t\tcase SHORT -> deserializeEnumSetShort(buf, e);\n\t\t\t\t};\n\t}\n\n\tprivate static <E extends Enum<E>> Set<E> deserializeEnumSetInt(ByteBuf buf, Class<E> e)\n\t{\n\t\tvar value = buf.readInt();\n\t\tlog.trace(\"Reading enumSet (int): {}\", value);\n\t\tvar enumSet = EnumSet.noneOf(e);\n\t\tfor (var enumConstant : e.getEnumConstants())\n\t\t{\n\t\t\tif ((value & (1 << enumConstant.ordinal())) != 0)\n\t\t\t{\n\t\t\t\tenumSet.add(enumConstant);\n\t\t\t}\n\t\t}\n\t\treturn enumSet;\n\t}\n\n\tprivate static <E extends Enum<E>> Set<E> deserializeEnumSetByte(ByteBuf buf, Class<E> e)\n\t{\n\t\tvar value = buf.readByte();\n\t\tlog.trace(\"Reading enumSet (byte): {}\", value);\n\t\tvar enumSet = EnumSet.noneOf(e);\n\t\tfor (var enumConstant : e.getEnumConstants())\n\t\t{\n\t\t\tif ((value & 0xff & (1 << enumConstant.ordinal())) != 0)\n\t\t\t{\n\t\t\t\tenumSet.add(enumConstant);\n\t\t\t}\n\t\t}\n\t\treturn enumSet;\n\t}\n\n\tprivate static <E extends Enum<E>> Set<E> deserializeEnumSetShort(ByteBuf buf, Class<E> e)\n\t{\n\t\tvar value = buf.readShort();\n\t\tlog.trace(\"Reading enumSet (long): {}\", value);\n\t\tvar enumSet = EnumSet.noneOf(e);\n\t\tfor (var enumConstant : e.getEnumConstants())\n\t\t{\n\t\t\tif ((value & (1 << enumConstant.ordinal())) != 0)\n\t\t\t{\n\t\t\t\tenumSet.add(enumConstant);\n\t\t\t}\n\t\t}\n\t\treturn enumSet;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/FieldSize.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\npublic enum FieldSize\n{\n\tBYTE,\n\tSHORT,\n\tINTEGER\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/FloatSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class FloatSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FloatSerializer.class);\n\n\tprivate FloatSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, float value)\n\t{\n\t\tlog.trace(\"Writing float: {}\", value);\n\t\tbuf.ensureWritable(Float.BYTES);\n\t\tbuf.writeFloat(value);\n\t\treturn Float.BYTES;\n\t}\n\n\tstatic float deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readFloat();\n\t\tlog.trace(\"Reading float: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/GxsMetaAndDataResult.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\npublic final class GxsMetaAndDataResult\n{\n\tprivate int dataSize;\n\tprivate int metaSize;\n\n\tpublic int getDataSize()\n\t{\n\t\treturn dataSize;\n\t}\n\n\tpublic void setDataSize(int dataSize)\n\t{\n\t\tthis.dataSize = dataSize;\n\t}\n\n\tpublic int getMetaSize()\n\t{\n\t\treturn metaSize;\n\t}\n\n\tpublic void setMetaSize(int metaSize)\n\t{\n\t\tthis.metaSize = metaSize;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/GxsMetaAndDataSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMetaAndData;\n\nimport java.util.Set;\n\nfinal class GxsMetaAndDataSerializer\n{\n\tprivate GxsMetaAndDataSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, GxsMetaAndData gxsMetaAndData, Set<SerializationFlags> flags, GxsMetaAndDataResult result)\n\t{\n\t\tvar dataSize = gxsMetaAndData.writeDataObject(buf, flags);\n\t\tvar metaSize = gxsMetaAndData.writeMetaObject(buf, flags);\n\n\t\tif (result != null)\n\t\t{\n\t\t\tresult.setDataSize(dataSize);\n\t\t\tresult.setMetaSize(metaSize);\n\t\t}\n\n\t\treturn dataSize + metaSize;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/IdentifierSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.common.id.Identifier;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.Arrays;\n\nfinal class IdentifierSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(IdentifierSerializer.class);\n\n\tprivate IdentifierSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Class<?> identifierClass, Identifier identifier)\n\t{\n\t\tlog.trace(\"Writing identifier: {}\", identifier);\n\t\tif (identifier == null)\n\t\t{\n\t\t\tvar nullIdentifierArray = getNullIdentifierArray(identifierClass);\n\t\t\tbuf.ensureWritable(nullIdentifierArray.length);\n\t\t\tbuf.writeBytes(nullIdentifierArray);\n\t\t\treturn nullIdentifierArray.length;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbuf.ensureWritable(identifier.getLength());\n\t\t\tbuf.writeBytes(identifier.getBytes());\n\t\t\treturn identifier.getLength();\n\t\t}\n\t}\n\n\tstatic Identifier deserialize(ByteBuf buf, Class<?> identifierClass)\n\t{\n\t\ttry\n\t\t{\n\t\t\t//noinspection PrimitiveArrayArgumentToVarargsMethod\n\t\t\tvar identifier = (Identifier) identifierClass.getDeclaredConstructor(byte[].class).newInstance(ByteArraySerializer.deserialize(buf, getIdentifierLength(identifierClass)));\n\t\t\tif (Arrays.equals(identifier.getNullIdentifier(), identifier.getBytes()))\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\treturn identifier;\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(e.getMessage());\n\t\t}\n\t}\n\n\tstatic Identifier deserializeWithSize(ByteBuf buf, Class<?> identifierClass, int size)\n\t{\n\t\ttry\n\t\t{\n\t\t\t//noinspection PrimitiveArrayArgumentToVarargsMethod\n\t\t\tvar identifier = (Identifier) identifierClass.getDeclaredConstructor(byte[].class).newInstance(ByteArraySerializer.deserialize(buf, size));\n\t\t\tif (Arrays.equals(identifier.getNullIdentifier(), identifier.getBytes()))\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\treturn identifier;\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(e.getMessage());\n\t\t}\n\t}\n\n\tstatic int getIdentifierLength(Class<?> identifierClass)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar field = identifierClass.getDeclaredField(Identifier.LENGTH_FIELD_NAME);\n\t\t\treturn (int) field.get(null);\n\t\t}\n\t\tcatch (NoSuchFieldException | IllegalAccessException _)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Missing LENGTH static field in \" + identifierClass.getSimpleName());\n\t\t}\n\t}\n\n\tprivate static byte[] getNullIdentifierArray(Class<?> identifierClass)\n\t{\n\t\t// Try finding a static field called \"NULL_IDENTIFIER\";\n\t\ttry\n\t\t{\n\t\t\tvar field = identifierClass.getDeclaredField(Identifier.NULL_FIELD_NAME);\n\t\t\treturn (byte[]) field.get(null);\n\t\t}\n\t\tcatch (NoSuchFieldException | IllegalAccessException _)\n\t\t{\n\t\t\t// No? Create an identifier instance then a null identifier. This requires\n\t\t\t// more resources but is the only way for identifiers that have a dynamic length.\n\t\t\tlog.warn(\"Using slow path to create a null identifier for {}, consider adding a static field called {} with a null instance in it\", identifierClass.getSimpleName(), Identifier.NULL_FIELD_NAME);\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar identifier = (Identifier) identifierClass.getDeclaredConstructor().newInstance();\n\t\t\t\treturn identifier.getNullIdentifier();\n\t\t\t}\n\t\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/IntSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class IntSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(IntSerializer.class);\n\n\tprivate IntSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, int value)\n\t{\n\t\tlog.trace(\"Writing int: {}\", value);\n\t\tbuf.ensureWritable(Integer.BYTES);\n\t\tbuf.writeInt(value);\n\t\treturn Integer.BYTES;\n\t}\n\n\tstatic int deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readInt();\n\t\tlog.trace(\"Reading int: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/ListSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.ParameterizedType;\nimport java.util.ArrayList;\nimport java.util.List;\n\nfinal class ListSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ListSerializer.class);\n\n\tprivate ListSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, List<Object> list)\n\t{\n\t\tvar size = Integer.BYTES;\n\n\t\tbuf.ensureWritable(size);\n\t\tif (list != null)\n\t\t{\n\t\t\tlog.trace(\"Entries in List: {}\", list.size());\n\t\t\tbuf.writeInt(list.size());\n\t\t\tfor (var data : list)\n\t\t\t{\n\t\t\t\tsize += Serializer.serialize(buf, data.getClass(), data, null);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbuf.writeInt(0);\n\t\t}\n\t\treturn size;\n\t}\n\n\tstatic int serialize(ByteBuf buf, List<Object> list, TlvType tlvType)\n\t{\n\t\tvar size = Integer.BYTES;\n\n\t\tbuf.ensureWritable(size);\n\t\tif (list != null)\n\t\t{\n\t\t\tlog.trace(\"Entries in List: {} with TlvType {}\", list.size(), tlvType);\n\t\t\tbuf.writeInt(list.size());\n\t\t\tfor (var data : list)\n\t\t\t{\n\t\t\t\tsize += TlvSerializer.serialize(buf, tlvType, data);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbuf.writeInt(0);\n\t\t}\n\t\treturn size;\n\t}\n\n\tstatic List<Object> deserialize(ByteBuf buf, List<Object> list, ParameterizedType type)\n\t{\n\t\tif (list == null)\n\t\t{\n\t\t\tlist = new ArrayList<>();\n\t\t}\n\n\t\tvar entries = buf.readInt();\n\t\tvar dataClass = (Class<?>) type.getActualTypeArguments()[0];\n\t\tlog.trace(\"Data class: {}\", dataClass.getSimpleName());\n\n\t\twhile (entries-- > 0)\n\t\t{\n\t\t\tvar dataObject = Serializer.deserialize(buf, dataClass);\n\t\t\tlog.trace(\"result: {}\", dataObject);\n\t\t\tlist.add(dataObject);\n\t\t}\n\t\treturn list;\n\t}\n\n\tstatic List<Object> deserialize(ByteBuf buf, List<Object> list, TlvType tlvType)\n\t{\n\t\tif (list == null)\n\t\t{\n\t\t\tlist = new ArrayList<>();\n\t\t}\n\n\t\tvar entries = buf.readInt();\n\n\t\twhile (entries-- > 0)\n\t\t{\n\t\t\tvar dataObject = TlvSerializer.deserialize(buf, tlvType);\n\t\t\tlog.trace(\"result: {} (tlvType: {})\", dataObject, tlvType);\n\t\t\tlist.add(dataObject);\n\t\t}\n\t\treturn list;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/LongSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class LongSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(LongSerializer.class);\n\n\tprivate LongSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, long value)\n\t{\n\t\tlog.trace(\"Writing long: {}\", value);\n\t\tbuf.ensureWritable(Long.BYTES);\n\t\tbuf.writeLong(value);\n\t\treturn Long.BYTES;\n\t}\n\n\tstatic long deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readLong();\n\t\tlog.trace(\"Reading long: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/MapSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.ParameterizedType;\nimport java.util.HashMap;\nimport java.util.Map;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class MapSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(MapSerializer.class);\n\n\tprivate MapSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Map<Object, Object> map)\n\t{\n\t\tvar size = 0;\n\n\t\tif (map != null && !map.isEmpty())\n\t\t{\n\t\t\tlog.trace(\"Entries in Map: {}\", map.size());\n\t\t\tvar mapSize = 0;\n\t\t\tvar mapSizeOffset = writeTlv(buf);\n\t\t\tfor (var entry : map.entrySet())\n\t\t\t{\n\t\t\t\tvar entrySizeOffset = writeTlv(buf);\n\t\t\t\tvar entrySize = 0;\n\t\t\t\tlog.trace(\"Writing Key class: {}\", entry.getKey().getClass().getSimpleName());\n\t\t\t\tentrySize += writeMapData(buf, entry.getKey());\n\t\t\t\tlog.trace(\"Writing Value class: {}\", entry.getValue().getClass().getSimpleName());\n\t\t\t\tentrySize += writeMapData(buf, entry.getValue());\n\t\t\t\tmapSize += writeTlvBack(buf, entrySizeOffset, entrySize);\n\t\t\t\tlog.trace(\"Writing total entry size of {}\", entrySize);\n\t\t\t}\n\t\t\tlog.trace(\"Writing total map size of {}\", mapSize);\n\t\t\tsize += writeTlvBack(buf, mapSizeOffset, mapSize);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsize += writeTlvBack(buf, writeTlv(buf), 0);\n\t\t}\n\t\treturn size;\n\t}\n\n\tstatic Map<Object, Object> deserialize(ByteBuf buf, Map<Object, Object> map, ParameterizedType type)\n\t{\n\t\tif (map == null)\n\t\t{\n\t\t\tmap = new HashMap<>();\n\t\t}\n\n\t\tvar mapSize = readTlv(buf);\n\t\tlog.trace(\"Map size: {}, readerIndex: {}\", mapSize, buf.readerIndex());\n\t\tvar mapIndex = buf.readerIndex();\n\n\t\twhile (buf.readerIndex() < mapIndex + mapSize - TLV_HEADER_SIZE)\n\t\t{\n\t\t\tlog.trace(\"buf.readerIndex: {}, mapIndex + mapSize: {}\", buf.readerIndex(), mapIndex + mapSize);\n\t\t\treadTlv(buf);\n\n\t\t\tvar keyClass = (Class<?>) type.getActualTypeArguments()[0];\n\t\t\tlog.trace(\"Key class: {}\", keyClass.getSimpleName());\n\t\t\tvar keyObject = readMapData(buf, keyClass);\n\t\t\tvar dataClass = (Class<?>) type.getActualTypeArguments()[1];\n\t\t\tlog.trace(\"Data class: {}\", dataClass.getSimpleName());\n\t\t\tvar dataObject = readMapData(buf, dataClass);\n\t\t\tlog.trace(\"result: {}\", dataObject);\n\n\t\t\tmap.put(keyObject, dataObject);\n\t\t}\n\t\tlog.trace(\"done: buf.readerIndex: {}\", buf.readerIndex());\n\t\treturn map;\n\t}\n\n\tprivate static int writeMapData(ByteBuf buf, Object object)\n\t{\n\t\tint size;\n\n\t\tvar sizeOffset = writeTlv(buf);\n\t\tsize = Serializer.serialize(buf, object.getClass(), object, null);\n\t\treturn writeTlvBack(buf, sizeOffset, size);\n\t}\n\n\t// XXX: we don't really need to check for the sizes everywhere. first deserialize can check the total size, then the rest just locally. just throw something if deserializing is wrong\n\n\tprivate static int writeTlvBack(ByteBuf buf, int offset, int size)\n\t{\n\t\tsize += TLV_HEADER_SIZE;\n\t\tbuf.setInt(offset, size);\n\t\treturn size;\n\t}\n\n\tprivate static int writeTlv(ByteBuf buf)\n\t{\n\t\tbuf.ensureWritable(TLV_HEADER_SIZE);\n\t\tbuf.writeShort(1);\n\t\tvar offset = buf.writerIndex();\n\t\tbuf.writerIndex(offset + 4);\n\t\treturn offset;\n\t}\n\n\tprivate static Object readMapData(ByteBuf buf, Class<?> javaClass)\n\t{\n\t\tvar size = readTlv(buf); // XXX: check size\n\t\tlog.trace(\"Reading map data of size: {}\", size);\n\n\t\treturn Serializer.deserialize(buf, javaClass);\n\t}\n\n\tprivate static int readTlv(ByteBuf buf)\n\t{\n\t\tif (buf.readShort() != 1)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Wrong TLV\");\n\t\t}\n\t\treturn buf.readInt();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/RsClassSerializedReversed.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\n\nimport static java.lang.annotation.ElementType.TYPE;\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\n/**\n * Marks an item class as requiring reverse serialization, that is,\n * the deepest subclass first, up to the item's first subclass.\n */\n@Retention(RUNTIME)\n@Target(TYPE)\npublic @interface RsClassSerializedReversed\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/RsSerializable.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\n\nimport java.util.Set;\n\npublic interface RsSerializable\n{\n\tint writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags);\n\n\tvoid readObject(ByteBuf buf);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/RsSerializableSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\n\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.EnumSet;\nimport java.util.Set;\n\nfinal class RsSerializableSerializer\n{\n\tprivate RsSerializableSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, RsSerializable rsSerializable)\n\t{\n\t\treturn rsSerializable.writeObject(buf, EnumSet.noneOf(SerializationFlags.class));\n\t}\n\n\tstatic int serialize(ByteBuf buf, RsSerializable rsSerializable, Set<SerializationFlags> flags)\n\t{\n\t\treturn rsSerializable.writeObject(buf, flags);\n\t}\n\n\tstatic Object deserialize(ByteBuf buf, Class<?> javaClass)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar instanceObject = javaClass.getDeclaredConstructor().newInstance();\n\t\t\t((RsSerializable) instanceObject).readObject(buf);\n\t\t\treturn instanceObject;\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException _)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Unhandled class \" + javaClass.getSimpleName());\n\t\t}\n\t}\n\n\tstatic void deserialize(ByteBuf buf, RsSerializable rsSerializable)\n\t{\n\t\trsSerializable.readObject(buf);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/RsSerialized.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\n\nimport static java.lang.annotation.ElementType.FIELD;\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\n/**\n * Marks an item's field as serializable.\n */\n@Retention(RUNTIME)\n@Target(FIELD)\npublic @interface RsSerialized\n{\n\t/**\n\t * Sets the TLV type, only useful for TLV fields.\n\t *\n\t * @return the TLV type (default: NONE)\n\t */\n\tTlvType tlvType() default TlvType.STR_NONE;\n\n\t/**\n\t * Sets the EnumSet's type size.\n\t *\n\t * @return the EnumSet's type size (default: INTEGER)\n\t */\n\tFieldSize fieldSize() default FieldSize.INTEGER;\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/SerializationFlags.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\npublic enum SerializationFlags\n{\n\tSIGNATURE,\n\tSIZE\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/Serializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMetaAndData;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.Field;\nimport java.lang.reflect.ParameterizedType;\nimport java.math.BigInteger;\nimport java.util.*;\n\n/**\n * Class to serialize data types into a format compatible with\n * Retroshare's wire protocol.\n */\npublic final class Serializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Serializer.class);\n\n\tpublic static final int TLV_HEADER_SIZE = 6;\n\n\tprivate Serializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Serializes an integer.\n\t *\n\t * @param buf the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, int value)\n\t{\n\t\treturn IntSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes an integer.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static int deserializeInt(ByteBuf buf)\n\t{\n\t\treturn IntSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a short.\n\t *\n\t * @param buf the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, short value)\n\t{\n\t\treturn ShortSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a short.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static short deserializeShort(ByteBuf buf)\n\t{\n\t\treturn ShortSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a byte.\n\t *\n\t * @param buf the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, byte value)\n\t{\n\t\treturn ByteSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a byte.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static byte deserializeByte(ByteBuf buf)\n\t{\n\t\treturn ByteSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a long.\n\t *\n\t * @param buf the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, long value)\n\t{\n\t\treturn LongSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a long.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static long deserializeLong(ByteBuf buf)\n\t{\n\t\treturn LongSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a float.\n\t *\n\t * @param buf the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, float value)\n\t{\n\t\treturn FloatSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a float.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static float deserializeFloat(ByteBuf buf)\n\t{\n\t\treturn FloatSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a double.\n\t *\n\t * @param buf the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, double value)\n\t{\n\t\treturn DoubleSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a double.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static double deserializeDouble(ByteBuf buf)\n\t{\n\t\treturn DoubleSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a boolean.\n\t *\n\t * @param buf  the buffer\n\t * @param value the value to serialize\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, boolean value)\n\t{\n\t\treturn BooleanSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a boolean.\n\t *\n\t * @param buf the buffer\n\t * @return the value\n\t */\n\tpublic static boolean deserializeBoolean(ByteBuf buf)\n\t{\n\t\treturn BooleanSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a string.\n\t *\n\t * @param buf the buffer\n\t * @param value   the string\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, String value)\n\t{\n\t\treturn StringSerializer.serialize(buf, value);\n\t}\n\n\t/**\n\t * Deserializes a string.\n\t *\n\t * @param buf the buffer\n\t * @return the string\n\t */\n\tpublic static String deserializeString(ByteBuf buf)\n\t{\n\t\treturn StringSerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes an identifier.\n\t *\n\t * @param buf        the buffer\n\t * @param identifier the identifier, can be null\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, Identifier identifier)\n\t{\n\t\treturn IdentifierSerializer.serialize(buf, identifier.getClass(), identifier);\n\t}\n\n\t/**\n\t * Serializes an identifier.\n\t *\n\t * @param buf             the buffer\n\t * @param identifier      the identifier, can be null\n\t * @param identifierClass the identifier class\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, Identifier identifier, Class<? extends Identifier> identifierClass)\n\t{\n\t\treturn IdentifierSerializer.serialize(buf, identifierClass, identifier);\n\t}\n\n\t/**\n\t * Deserializes an identifier.\n\t *\n\t * @param buf             the buffer\n\t * @param identifierClass the class of the identifier\n\t * @return the identifier\n\t */\n\tpublic static Identifier deserializeIdentifier(ByteBuf buf, Class<?> identifierClass)\n\t{\n\t\treturn IdentifierSerializer.deserialize(buf, identifierClass);\n\t}\n\n\t/**\n\t * Deserializes an identifier while specifying its size.\n\t * <p>\n\t * This is required for some identifier that can have a varying size, like {@link ProfileFingerprint}.\n\t *\n\t * @param buf             the buffer\n\t * @param identifierClass the class of the identifier\n\t * @param size            the size to deserialize\n\t * @return the identifier\n\t */\n\tpublic static Identifier deserializeIdentifierWithSize(ByteBuf buf, Class<?> identifierClass, int size)\n\t{\n\t\treturn IdentifierSerializer.deserializeWithSize(buf, identifierClass, size);\n\t}\n\n\t/**\n\t * Serializes a byte array.\n\t *\n\t * @param buf the buffer\n\t * @param a   the byte array, can be null\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, byte[] a)\n\t{\n\t\treturn ByteArraySerializer.serialize(buf, a);\n\t}\n\n\t/**\n\t * Deserializes a byte array.\n\t *\n\t * @param buf the buffer\n\t * @return the byte array\n\t */\n\tpublic static byte[] deserializeByteArray(ByteBuf buf)\n\t{\n\t\treturn ByteArraySerializer.deserialize(buf);\n\t}\n\n\t/**\n\t * Serializes a map.\n\t *\n\t * @param buf the buffer\n\t * @param map the map, can be null\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, Map<Object, Object> map)\n\t{\n\t\treturn MapSerializer.serialize(buf, map);\n\t}\n\n\t/**\n\t * Deserializes a map.\n\t *\n\t * @param buf  the buffer\n\t * @param type the map key type and the map entry type\n\t * @return the map\n\t */\n\tpublic static Map<Object, Object> deserializeMap(ByteBuf buf, ParameterizedType type)\n\t{\n\t\treturn MapSerializer.deserialize(buf, null, type);\n\t}\n\n\t/**\n\t * Serializes a list.\n\t *\n\t * @param buf  the buffer\n\t * @param list the list, can be null\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, List<Object> list)\n\t{\n\t\treturn ListSerializer.serialize(buf, list);\n\t}\n\n\tpublic static int serialize(ByteBuf buf, List<Object> list, TlvType tlvType)\n\t{\n\t\treturn ListSerializer.serialize(buf, list, tlvType);\n\t}\n\n\t/**\n\t * Deserializes a list.\n\t *\n\t * @param buf  the buffer\n\t * @param type the list type\n\t * @return the list\n\t */\n\tpublic static List<Object> deserializeList(ByteBuf buf, ParameterizedType type)\n\t{\n\t\treturn ListSerializer.deserialize(buf, null, type);\n\t}\n\n\tpublic static List<Object> deserializeList(ByteBuf buf, TlvType tlvType)\n\t{\n\t\treturn ListSerializer.deserialize(buf, null, tlvType);\n\t}\n\n\t/**\n\t * Serializes an enum set.\n\t *\n\t * @param buf       the buffer\n\t * @param enumSet   the enum set\n\t * @param fieldSize the size of the enum set bitfield\n\t * @return the number of bytes taken to serialize\n\t */\n\tpublic static int serialize(ByteBuf buf, Set<? extends Enum<?>> enumSet, FieldSize fieldSize)\n\t{\n\t\treturn EnumSetSerializer.serialize(buf, enumSet, fieldSize);\n\t}\n\n\t/**\n\t * Deserializes an enum set.\n\t *\n\t * @param buf       the buffer\n\t * @param e         the enum class\n\t * @param fieldSize the size of the enum set bitfield\n\t * @return the enum set\n\t */\n\tpublic static <E extends Enum<E>> Set<E> deserializeEnumSet(ByteBuf buf, Class<E> e, FieldSize fieldSize)\n\t{\n\t\treturn EnumSetSerializer.deserialize(buf, e, fieldSize);\n\t}\n\n\t/**\n\t * Serializes an enum.\n\t *\n\t * @param buf the buffer\n\t * @param e   the enum\n\t * @return the number of bytes taken\n\t */\n\tpublic static int serialize(ByteBuf buf, Enum<?> e)\n\t{\n\t\treturn EnumSerializer.serialize(buf, e);\n\t}\n\n\t/**\n\t * Deserializes an enum.\n\t *\n\t * @param buf the buffer\n\t * @param e   the enum class\n\t * @return the enum\n\t */\n\tpublic static <E extends Enum<E>> E deserializeEnum(ByteBuf buf, Class<E> e)\n\t{\n\t\treturn EnumSerializer.deserialize(buf, e);\n\t}\n\n\t/**\n\t * Serializes a TLV.\n\t *\n\t * @param buf   the buffer\n\t * @param type  the type of the TLV\n\t * @param value the value\n\t * @return the number of bytes taken\n\t */\n\tpublic static int serialize(ByteBuf buf, TlvType type, Object value)\n\t{\n\t\treturn TlvSerializer.serialize(buf, type, value);\n\t}\n\n\t/**\n\t * Deserializes a TLV.\n\t *\n\t * @param buf  the buffer\n\t * @param type the type of the TLV\n\t * @return the value\n\t */\n\tpublic static Object deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\treturn TlvSerializer.deserialize(buf, type);\n\t}\n\n\t/**\n\t * Serializes a TLV binary with a defined type (needed for GXS)\n\t *\n\t * @param buf  the buffer\n\t * @param type the type (usually abused to be a service)\n\t * @param data the byte array\n\t * @return the number of bytes taken\n\t */\n\tpublic static int serializeTlvBinary(ByteBuf buf, int type, byte[] data)\n\t{\n\t\treturn TlvBinarySerializer.serialize(buf, type, data);\n\t}\n\n\t/**\n\t * Deserializes a TLV binary with a defined type (needed for GXS)\n\t *\n\t * @param buf  the buffer\n\t * @param type the type (usually abused to be a service)\n\t * @return the byte array\n\t */\n\tpublic static byte[] deserializeTlvBinary(ByteBuf buf, int type)\n\t{\n\t\treturn TlvBinarySerializer.deserialize(buf, type);\n\t}\n\n\t/**\n\t * Serializes all the annotated fields of an object.\n\t *\n\t * @param buf    the buffer\n\t * @param object the object with the annotated fields\n\t * @return the number of bytes taken\n\t */\n\tpublic static int serializeAnnotatedFields(ByteBuf buf, Object object)\n\t{\n\t\treturn AnnotationSerializer.serialize(buf, object);\n\t}\n\n\t/**\n\t * Deserializes all the annotated fields of an object.\n\t *\n\t * @param buf    the buffer\n\t * @param object the object with the annotated fields\n\t * @return true if at least one field was deserialized\n\t */\n\tpublic static boolean deserializeAnnotatedFields(ByteBuf buf, Object object)\n\t{\n\t\treturn AnnotationSerializer.deserialize(buf, object);\n\t}\n\n\tpublic static int serializeRsSerializable(ByteBuf buf, RsSerializable rsSerializable, Set<SerializationFlags> flags)\n\t{\n\t\treturn RsSerializableSerializer.serialize(buf, rsSerializable, flags);\n\t}\n\n\tpublic static void deserializeRsSerializable(ByteBuf buf, RsSerializable rsSerializable)\n\t{\n\t\tRsSerializableSerializer.deserialize(buf, rsSerializable);\n\t}\n\n\tpublic static int serializeGxsMetaAndDataItem(ByteBuf buf, GxsMetaAndData gxsMetaAndData, Set<SerializationFlags> flags, GxsMetaAndDataResult result)\n\t{\n\t\treturn GxsMetaAndDataSerializer.serialize(buf, gxsMetaAndData, flags, result);\n\t}\n\n\tstatic int serialize(ByteBuf buf, Field field, Object object)\n\t{\n\t\treturn serialize(buf, field.getType(), getField(field, object), field.getAnnotation(RsSerialized.class));\n\t}\n\n\t@SuppressWarnings(\"unchecked\")\n\tstatic int serialize(ByteBuf buf, Class<?> javaClass, Object object, RsSerialized annotation)\n\t{\n\t\tvar size = 0;\n\n\t\tlog.trace(\"Serializing...\");\n\n\t\tif (annotation != null && annotation.tlvType() != TlvType.STR_NONE)\n\t\t{\n\t\t\tsize += TlvSerializer.serialize(buf, annotation.tlvType(), object);\n\t\t}\n\t\telse if (Map.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\tsize += MapSerializer.serialize(buf, (Map<Object, Object>) object);\n\t\t}\n\t\telse if (List.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\tsize += ListSerializer.serialize(buf, (List<Object>) object);\n\t\t}\n\t\telse if (EnumSet.class.isAssignableFrom(javaClass) || Set.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\tsize += EnumSetSerializer.serialize(buf, (EnumSet<?>) object, annotation);\n\t\t}\n\t\telse if (Enum.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\tsize += EnumSerializer.serialize(buf, (Enum<?>) object);\n\t\t}\n\t\telse if (javaClass.equals(int.class) || javaClass.equals(Integer.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null integers not supported\");\n\t\t\tsize += IntSerializer.serialize(buf, (int) object);\n\t\t}\n\t\telse if (javaClass.equals(short.class) || javaClass.equals(Short.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null shorts not supported\");\n\t\t\tsize += ShortSerializer.serialize(buf, (short) object);\n\t\t}\n\t\telse if (javaClass.equals(byte.class) || javaClass.equals(Byte.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null bytes not supported\");\n\t\t\tsize += ByteSerializer.serialize(buf, (byte) object);\n\t\t}\n\t\telse if (javaClass.equals(long.class) || javaClass.equals(Long.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null longs not supported\");\n\t\t\tsize += LongSerializer.serialize(buf, (long) object);\n\t\t}\n\t\telse if (javaClass.equals(float.class) || javaClass.equals(Float.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null floats not supported\");\n\t\t\tsize += FloatSerializer.serialize(buf, (float) object);\n\t\t}\n\t\telse if (javaClass.equals(double.class) || javaClass.equals(Double.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null doubles not supported\");\n\t\t\tsize += DoubleSerializer.serialize(buf, (double) object);\n\t\t}\n\t\telse if (javaClass.equals(boolean.class) || javaClass.equals(Boolean.class))\n\t\t{\n\t\t\tObjects.requireNonNull(object, \"Null booleans not supported\");\n\t\t\tsize += BooleanSerializer.serialize(buf, (boolean) object);\n\t\t}\n\t\telse if (javaClass.equals(String.class))\n\t\t{\n\t\t\tsize += StringSerializer.serialize(buf, (String) object);\n\t\t}\n\t\telse if (javaClass.equals(BigInteger.class))\n\t\t{\n\t\t\tsize += BigIntegerSerializer.serialize(buf, (BigInteger) object);\n\t\t}\n\t\telse if (javaClass.isArray())\n\t\t{\n\t\t\tsize += ArraySerializer.serialize(buf, javaClass, object);\n\t\t}\n\t\telse if (Identifier.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\tsize += IdentifierSerializer.serialize(buf, javaClass, (Identifier) object);\n\t\t}\n\t\telse if (RsSerializable.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\tsize += RsSerializableSerializer.serialize(buf, (RsSerializable) object);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tcheckForNonAllowedType(javaClass);\n\t\t\tsize += AnnotationSerializer.serialize(buf, object);\n\t\t}\n\t\treturn size;\n\t}\n\n\tstatic void deserialize(ByteBuf buf, Field field, Object object, RsSerialized annotation)\n\t{\n\t\tsetField(field, object, deserialize(buf, field.getType(), field, object, annotation));\n\t}\n\n\tstatic Object deserialize(ByteBuf buf, Class<?> javaClass)\n\t{\n\t\treturn deserialize(buf, javaClass, null, null, null);\n\t}\n\n\t@SuppressWarnings(\"unchecked\")\n\tprivate static Object deserialize(ByteBuf buf, Class<?> javaClass, Field field, Object object, RsSerialized annotation)\n\t{\n\t\tif (annotation != null && annotation.tlvType() != TlvType.STR_NONE)\n\t\t{\n\t\t\treturn TlvSerializer.deserialize(buf, annotation.tlvType());\n\t\t}\n\t\telse if (javaClass.equals(int.class) || javaClass.equals(Integer.class))\n\t\t{\n\t\t\treturn IntSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(short.class) || javaClass.equals(Short.class))\n\t\t{\n\t\t\treturn ShortSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(byte.class) || javaClass.equals(Byte.class))\n\t\t{\n\t\t\treturn ByteSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(long.class) || javaClass.equals(Long.class))\n\t\t{\n\t\t\treturn LongSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(float.class) || javaClass.equals(Float.class))\n\t\t{\n\t\t\treturn FloatSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(double.class) || javaClass.equals(Double.class))\n\t\t{\n\t\t\treturn DoubleSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(boolean.class) || javaClass.equals(Boolean.class))\n\t\t{\n\t\t\treturn BooleanSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(String.class))\n\t\t{\n\t\t\treturn StringSerializer.deserialize(buf);\n\t\t}\n\t\telse if (javaClass.equals(BigInteger.class))\n\t\t{\n\t\t\treturn BigIntegerSerializer.deserialize(buf);\n\t\t}\n\t\telse if (Identifier.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\treturn IdentifierSerializer.deserialize(buf, javaClass);\n\t\t}\n\t\telse if (RsSerializable.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\treturn RsSerializableSerializer.deserialize(buf, javaClass);\n\t\t}\n\t\telse if (javaClass.isArray())\n\t\t{\n\t\t\treturn ArraySerializer.deserialize(buf, javaClass);\n\t\t}\n\t\telse if (Map.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\treturn MapSerializer.deserialize(buf, (Map<Object, Object>) getField(field, object), (ParameterizedType) field.getGenericType());\n\t\t}\n\t\telse if (List.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\treturn ListSerializer.deserialize(buf, (List<Object>) getField(field, object), (ParameterizedType) field.getGenericType());\n\t\t}\n\t\telse if (EnumSet.class.isAssignableFrom(javaClass) || Set.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\treturn EnumSetSerializer.deserialize(buf, (ParameterizedType) field.getGenericType(), annotation);\n\t\t}\n\t\telse if (Enum.class.isAssignableFrom(javaClass))\n\t\t{\n\t\t\treturn EnumSerializer.deserialize(buf, javaClass);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tcheckForNonAllowedType(javaClass);\n\t\t\treturn AnnotationSerializer.deserializeForClass(buf, javaClass);\n\t\t}\n\t}\n\n\tprivate static Object getField(Field field, Object object)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn field.get(object);\n\t\t}\n\t\tcatch (IllegalAccessException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Can't access field \" + field + \": \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\t@SuppressWarnings(\"java:S3011\") // Accessibility bypass\n\tprivate static void setField(Field field, Object object, Object value)\n\t{\n\t\ttry\n\t\t{\n\t\t\tfield.set(object, value);\n\t\t}\n\t\tcatch (IllegalAccessException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Can't set field \" + field + \": \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\t/**\n\t * Checks that a class is allowed for serialization. Retroshare is C++ so compound types should be disallowed; but they are used for lists and maps, and we cannot check them here.\n\t *\n\t * @param javaClass the class to check for support, an IllegalArgumentException is thrown if it is not supported\n\t */\n\tprivate static void checkForNonAllowedType(Class<?> javaClass)\n\t{\n\t\tif (javaClass.equals(Character.class)\n\t\t\t\t|| javaClass.equals(char.class))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Class \" + javaClass.getSimpleName() + \" is not allowed for serialization\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/SerializerSizeCache.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemUtils;\nimport io.xeres.app.xrs.service.RsService;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * This class allows to speed up the case when the size of a serialized item is needed frequently.\n * Only use it with items that cannot have a varying size depending on the item's field values.\n * Also note that incoming items have their serialized size recorded after deserialization, and outgoing items\n * return their serialized size when they're written to the wire, making this cache pretty much unnecessary.\n */\npublic final class SerializerSizeCache\n{\n\tprivate static final Map<Class<? extends Item>, Integer> cache = new HashMap<>();\n\n\tprivate SerializerSizeCache()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Gets the size of an item once it's serialized.\n\t * <p>\n\t * The result is cached so make sure this is only used with items whose serialized size cannot vary depending on their content.\n\t *\n\t * @param item the item\n\t * @return the size of the item after serialization, header included\n\t */\n\tpublic static int getItemSize(Item item, RsService service)\n\t{\n\t\treturn cache.computeIfAbsent(item.getClass(), aClass -> ItemUtils.getItemSerializedSize(item, service));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/ShortSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class ShortSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ShortSerializer.class);\n\n\tprivate ShortSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"SameReturnValue\")\n\tstatic int serialize(ByteBuf buf, short value)\n\t{\n\t\tlog.trace(\"Writing short: {}\", value);\n\t\tbuf.ensureWritable(Short.BYTES);\n\t\tbuf.writeShort(value);\n\t\treturn Short.BYTES;\n\t}\n\n\tstatic short deserialize(ByteBuf buf)\n\t{\n\t\tvar val = buf.readShort();\n\t\tlog.trace(\"Reading short: {}\", val);\n\t\treturn val;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/StringSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nfinal class StringSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(StringSerializer.class);\n\n\tprivate StringSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, String value)\n\t{\n\t\tlog.trace(\"Writing string: \\\"{}\\\"\", value);\n\t\tif (value == null)\n\t\t{\n\t\t\tbuf.ensureWritable(Integer.BYTES);\n\t\t\tbuf.writeInt(0);\n\t\t\treturn Integer.BYTES;\n\t\t}\n\t\tvar bytes = value.getBytes();\n\t\tbuf.ensureWritable(Integer.BYTES + bytes.length);\n\t\tbuf.writeInt(bytes.length);\n\t\tbuf.writeBytes(bytes);\n\t\treturn Integer.BYTES + bytes.length;\n\t}\n\n\tstatic String deserialize(ByteBuf buf)\n\t{\n\t\tvar len = buf.readInt();\n\t\tlog.trace(\"Reading string of length: {}\", len);\n\t\tvar out = new byte[len];\n\t\tbuf.readBytes(out);\n\t\treturn new String(out);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvAddressSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvAddressSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvAddressSerializer.class);\n\n\tprivate TlvAddressSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, PeerAddress peerAddress)\n\t{\n\t\tbuf.ensureWritable(peerAddress == null ? TLV_HEADER_SIZE : (TLV_HEADER_SIZE * 2 + 6));\n\t\tbuf.writeShort(ADDRESS.getValue());\n\n\t\tif (peerAddress == null)\n\t\t{\n\t\t\tbuf.writeInt(TLV_HEADER_SIZE);\n\t\t\treturn TLV_HEADER_SIZE;\n\t\t}\n\n\t\tswitch (peerAddress.getType())\n\t\t{\n\t\t\tcase IPV4 -> {\n\t\t\t\tbuf.writeInt(TLV_HEADER_SIZE * 2 + 6);\n\t\t\t\tbuf.writeShort(IPV4.getValue());\n\t\t\t\tbuf.writeInt(TLV_HEADER_SIZE + 6);\n\t\t\t\tvar address = peerAddress.getAddressAsBytes().orElseThrow();\n\t\t\t\t// RS expects little endian\n\t\t\t\tbuf.writeByte(address[3]);\n\t\t\t\tbuf.writeByte(address[2]);\n\t\t\t\tbuf.writeByte(address[1]);\n\t\t\t\tbuf.writeByte(address[0]);\n\t\t\t\tbuf.writeByte(address[5]);\n\t\t\t\tbuf.writeByte(address[4]);\n\t\t\t\treturn TLV_HEADER_SIZE * 2 + 6;\n\t\t\t}\n\t\t\tdefault -> throw new IllegalArgumentException(\"Unsupported address type \" + peerAddress.getType().name());\n\t\t}\n\t}\n\n\tstatic PeerAddress deserialize(ByteBuf buf)\n\t{\n\t\tvar type = buf.readUnsignedShort();\n\t\tlog.trace(\"Address type: {}\", type);\n\n\t\tif (type == ADDRESS.getValue())\n\t\t{\n\t\t\tvar totalSize = buf.readInt(); // XXX: check size\n\n\t\t\tif (totalSize > TLV_HEADER_SIZE)\n\t\t\t{\n\t\t\t\tvar addressType = buf.readUnsignedShort();\n\t\t\t\tvar addressSize = buf.readInt();\n\n\t\t\t\tif (addressType == IPV4.getValue())\n\t\t\t\t{\n\t\t\t\t\tif (addressSize != TLV_HEADER_SIZE + 6)\n\t\t\t\t\t{\n\t\t\t\t\t\tthrow new IllegalArgumentException(\"Wrong IPV4 address size: \" + addressSize);\n\t\t\t\t\t}\n\t\t\t\t\tlog.trace(\"reading IPv4 address of {} bytes\", addressSize);\n\t\t\t\t\tvar address = new byte[6];\n\n\t\t\t\t\t// RS stores both in little endian\n\t\t\t\t\taddress[3] = buf.readByte();\n\t\t\t\t\taddress[2] = buf.readByte();\n\t\t\t\t\taddress[1] = buf.readByte();\n\t\t\t\t\taddress[0] = buf.readByte();\n\n\t\t\t\t\taddress[5] = buf.readByte();\n\t\t\t\t\taddress[4] = buf.readByte();\n\n\t\t\t\t\treturn PeerAddress.fromByteArray(address);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog.trace(\"Skipping unsupported address type {}, size: {}\", addressType, addressSize);\n\t\t\t\t\tbuf.skipBytes(addressSize - TLV_HEADER_SIZE);\n\t\t\t\t\treturn PeerAddress.fromInvalid();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unrecognized address \" + type);\n\t\t}\n\t\treturn PeerAddress.fromInvalid();\n\t}\n\n\tstatic int serializeList(ByteBuf buf, List<PeerAddress> addresses)\n\t{\n\t\tbuf.ensureWritable(TLV_HEADER_SIZE);\n\t\tbuf.writeShort(ADDRESS_SET.getValue());\n\t\tvar totalSize = TLV_HEADER_SIZE;\n\t\tvar totalSizeOffset = buf.writerIndex();\n\t\tbuf.writeInt(0);\n\n\t\tif (addresses != null)\n\t\t{\n\t\t\tfor (var address : addresses)\n\t\t\t{\n\t\t\t\tvar size = TLV_HEADER_SIZE + 12; // long + int below\n\t\t\t\tbuf.writeShort(ADDRESS_INFO.getValue());\n\t\t\t\tvar sizeOffset = buf.writerIndex();\n\t\t\t\tbuf.writeInt(0);\n\t\t\t\tsize += serialize(buf, address);\n\t\t\t\tbuf.writeLong(0); // XXX: seenTime (64-bits)... we don't have that in PeerAddress... where do we get it from?!\n\t\t\t\tbuf.writeInt(0); // XXX: source (32-bits)... likewise\n\t\t\t\tbuf.setInt(sizeOffset, size);\n\t\t\t\ttotalSize += size;\n\t\t\t}\n\t\t}\n\t\tbuf.setInt(totalSizeOffset, totalSize);\n\t\treturn totalSize;\n\t}\n\n\tstatic List<PeerAddress> deserializeList(ByteBuf buf)\n\t{\n\t\tvar addresses = new ArrayList<PeerAddress>();\n\n\t\tvar totalSize = TlvUtils.checkTypeAndLength(buf, ADDRESS_SET);\n\t\tvar index = buf.readerIndex();\n\n\t\twhile (buf.readerIndex() < index + totalSize)\n\t\t{\n\t\t\tvar size = TlvUtils.checkTypeAndLength(buf, ADDRESS_INFO);\n\t\t\tif (size > 0)\n\t\t\t{\n\t\t\t\tvar peerAddress = deserialize(buf);\n\t\t\t\tif (peerAddress.isValid())\n\t\t\t\t{\n\t\t\t\t\taddresses.add(peerAddress);\n\t\t\t\t}\n\t\t\t\tbuf.readLong(); // XXX: seenTime\n\t\t\t\tbuf.readInt(); // XXX: source\n\t\t\t}\n\t\t}\n\t\treturn addresses;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvBinarySerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvBinarySerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvBinarySerializer.class);\n\n\tprivate TlvBinarySerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, byte[] data)\n\t{\n\t\treturn serialize(buf, TlvType.STR_NONE, data);\n\t}\n\n\tstatic int serialize(ByteBuf buf, TlvType type, byte[] data)\n\t{\n\t\treturn serialize(buf, type.getValue(), data);\n\t}\n\n\tstatic int serialize(ByteBuf buf, int type, byte[] data)\n\t{\n\t\tif (data == null)\n\t\t{\n\t\t\tdata = new byte[0];\n\t\t}\n\n\t\tvar len = getSize(data);\n\t\tlog.trace(\"Writing TLV binary data (size: {})\", data.length);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type);\n\t\tbuf.writeInt(len);\n\t\tif (data.length > 0)\n\t\t{\n\t\t\tbuf.writeBytes(data);\n\t\t}\n\t\treturn len;\n\t}\n\n\tstatic int getSize(byte[] data)\n\t{\n\t\treturn TLV_HEADER_SIZE + (data != null ? data.length : 0);\n\t}\n\n\tstatic byte[] deserialize(ByteBuf buf)\n\t{\n\t\treturn deserialize(buf, TlvType.STR_NONE);\n\t}\n\n\tstatic byte[] deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\treturn deserialize(buf, type.getValue());\n\t}\n\n\tstatic byte[] deserialize(ByteBuf buf, int type)\n\t{\n\t\tlog.trace(\"Reading TLV binary\");\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, type);\n\t\tlog.trace(\"  of {} bytes\", len);\n\t\tvar out = new byte[len];\n\t\tbuf.readBytes(out);\n\t\treturn out;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvFileDataSerializer.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.FileData;\nimport io.xeres.app.xrs.common.FileItem;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvFileDataSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvFileDataSerializer.class);\n\n\tprivate TlvFileDataSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, FileData fileData)\n\t{\n\t\tlog.trace(\"Writing TlvFileData\");\n\n\t\tvar len = getSize(fileData);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(FILE_DATA.getValue());\n\t\tbuf.writeInt(len);\n\t\tTlvFileItemSerializer.serialize(buf, fileData.fileItem());\n\t\tTlvSerializer.serialize(buf, LONG_OFFSET, fileData.offset());\n\t\tTlvBinarySerializer.serialize(buf, BIN_FILE_DATA, fileData.data());\n\t\treturn len;\n\t}\n\n\tstatic int getSize(FileData fileData)\n\t{\n\t\treturn TLV_HEADER_SIZE +\n\t\t\t\tTlvFileItemSerializer.getSize(fileData.fileItem()) +\n\t\t\t\tTlvUint64Serializer.getSize() +\n\t\t\t\tTlvBinarySerializer.getSize(fileData.data());\n\t}\n\n\tstatic FileData deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvFileData\");\n\n\t\tTlvUtils.checkTypeAndLength(buf, FILE_DATA);\n\n\t\tvar fileItem = (FileItem) TlvSerializer.deserialize(buf, FILE_ITEM);\n\t\tvar offset = (long) TlvSerializer.deserialize(buf, LONG_OFFSET);\n\t\tvar data = TlvBinarySerializer.deserialize(buf, BIN_FILE_DATA);\n\t\treturn new FileData(fileItem, offset, data);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvFileItemSerializer.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.common.id.Sha1Sum;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvFileItemSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvFileItemSerializer.class);\n\n\tprivate TlvFileItemSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, FileItem fileItem)\n\t{\n\t\tlog.trace(\"Writing TlvFileItem\");\n\n\t\tvar len = getSize(fileItem);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(FILE_ITEM.getValue());\n\t\tbuf.writeInt(len);\n\t\tbuf.writeLong(fileItem.size());\n\t\tSerializer.serialize(buf, fileItem.hash());\n\t\tif (StringUtils.isNotEmpty(fileItem.name()))\n\t\t{\n\t\t\tTlvSerializer.serialize(buf, STR_NAME, fileItem.name());\n\t\t}\n\t\tif (StringUtils.isNotEmpty(fileItem.path()))\n\t\t{\n\t\t\tTlvSerializer.serialize(buf, STR_PATH, fileItem.path());\n\t\t}\n\t\tif (fileItem.age() != 0)\n\t\t{\n\t\t\tTlvSerializer.serialize(buf, INT_AGE, fileItem.age());\n\t\t}\n\t\treturn len;\n\t}\n\n\tstatic int getSize(FileItem fileItem)\n\t{\n\t\treturn TLV_HEADER_SIZE +\n\t\t\t\t8 +\n\t\t\t\tSha1Sum.LENGTH +\n\t\t\t\t(StringUtils.isEmpty(fileItem.name()) ? 0 : TlvStringSerializer.getSize(fileItem.name())) +\n\t\t\t\t(StringUtils.isEmpty(fileItem.path()) ? 0 : TlvStringSerializer.getSize(fileItem.path())) +\n\t\t\t\t(fileItem.age() == 0 ? 0 : TlvUint32Serializer.getSize());\n\t}\n\n\tstatic FileItem deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvFileItem\");\n\n\t\tvar totalSize = TlvUtils.checkTypeAndLength(buf, FILE_ITEM);\n\t\tvar index = buf.readerIndex();\n\t\tvar size = Serializer.deserializeLong(buf);\n\t\tvar hash = (Sha1Sum) Serializer.deserializeIdentifier(buf, Sha1Sum.class);\n\n\t\tTlvType tlvType;\n\t\tString name = null;\n\t\tString path = null;\n\t\tvar age = 0;\n\t\twhile (buf.readerIndex() < index + totalSize && (tlvType = TlvUtils.peekTlvType(buf)) != null)\n\t\t{\n\t\t\tswitch (tlvType)\n\t\t\t{\n\t\t\t\tcase STR_NAME -> name = (String) TlvSerializer.deserialize(buf, STR_NAME);\n\t\t\t\tcase STR_PATH -> path = (String) TlvSerializer.deserialize(buf, STR_PATH);\n\t\t\t\tcase INT_POPULARITY -> TlvSerializer.deserialize(buf, INT_POPULARITY);\n\t\t\t\tcase INT_AGE -> age = (int) TlvSerializer.deserialize(buf, INT_AGE);\n\t\t\t\tcase INT_SIZE -> TlvSerializer.deserialize(buf, INT_SIZE);\n\t\t\t\tcase SET_HASH -> TlvSerializer.deserialize(buf, SET_HASH);\n\t\t\t\tdefault -> TlvUtils.skipTlv(buf);\n\t\t\t}\n\t\t}\n\t\treturn new FileItem(size, hash, name, path, age);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvFileSetSerializer.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.app.xrs.common.FileSet;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvFileSetSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvFileSetSerializer.class);\n\n\tprivate TlvFileSetSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, FileSet fileSet)\n\t{\n\t\tlog.trace(\"Writing TlvFileSet\");\n\n\t\tvar len = getSize(fileSet);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(FILE_SET.getValue());\n\t\tbuf.writeInt(len);\n\t\tfileSet.fileItems()\n\t\t\t\t.forEach(fileItem -> TlvFileItemSerializer.serialize(buf, fileItem));\n\t\tif (StringUtils.isNotEmpty(fileSet.title()))\n\t\t{\n\t\t\tTlvSerializer.serialize(buf, STR_TITLE, fileSet.title());\n\t\t}\n\t\tif (StringUtils.isNotEmpty(fileSet.comment()))\n\t\t{\n\t\t\tTlvSerializer.serialize(buf, STR_COMMENT, fileSet.comment());\n\t\t}\n\t\treturn len;\n\t}\n\n\tstatic int getSize(FileSet fileSet)\n\t{\n\t\treturn TLV_HEADER_SIZE +\n\t\t\t\tfileSet.fileItems().stream().mapToInt(TlvFileItemSerializer::getSize).sum() +\n\t\t\t\t(StringUtils.isEmpty(fileSet.title()) ? 0 : TlvStringSerializer.getSize(fileSet.title())) +\n\t\t\t\t(StringUtils.isEmpty(fileSet.comment()) ? 0 : TlvStringSerializer.getSize(fileSet.comment()));\n\t}\n\n\tstatic FileSet deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvFileSet\");\n\n\t\tvar totalSize = TlvUtils.checkTypeAndLength(buf, FILE_SET);\n\t\tvar index = buf.readerIndex();\n\n\t\tTlvType tlvType;\n\t\tString title = null;\n\t\tString comment = null;\n\t\tList<FileItem> fileItems = new ArrayList<>();\n\t\twhile (buf.readerIndex() < index + totalSize && (tlvType = TlvUtils.peekTlvType(buf)) != null)\n\t\t{\n\t\t\tswitch (tlvType)\n\t\t\t{\n\t\t\t\tcase FILE_ITEM -> fileItems.add(TlvFileItemSerializer.deserialize(buf));\n\t\t\t\tcase STR_TITLE -> title = (String) TlvSerializer.deserialize(buf, STR_TITLE);\n\t\t\t\tcase STR_COMMENT -> comment = (String) TlvSerializer.deserialize(buf, STR_COMMENT);\n\t\t\t\tdefault -> TlvUtils.skipTlv(buf);\n\t\t\t}\n\t\t}\n\t\treturn new FileSet(fileItems, title, comment);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvImageSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.BIN_IMAGE;\nimport static io.xeres.app.xrs.serialization.TlvType.IMAGE;\n\nfinal class TlvImageSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvImageSerializer.class);\n\n\tpublic enum ImageType\n\t{\n\t\tAUTO_DETECT, // Retroshare always sends this (supposedly PNG). We assume we have to look into the data to know what it is.\n\t\tPNG,\n\t\tJPEG\n\t}\n\n\tprivate TlvImageSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, byte[] image)\n\t{\n\t\tlog.trace(\"Writing image\");\n\n\t\tvar len = getSize(image);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(IMAGE.getValue());\n\t\tbuf.writeInt(len);\n\t\tEnumSerializer.serialize(buf, ImageType.AUTO_DETECT);\n\t\tTlvSerializer.serialize(buf, BIN_IMAGE, image);\n\n\t\treturn len;\n\t}\n\n\tstatic int getSize(byte[] data)\n\t{\n\t\treturn TLV_HEADER_SIZE + EnumSerializer.getSize() + TlvBinarySerializer.getSize(data);\n\t}\n\n\tstatic byte[] deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading image\");\n\n\t\tTlvUtils.checkTypeAndLength(buf, IMAGE);\n\t\tEnumSerializer.deserialize(buf, ImageType.class); // Not really used\n\t\treturn (byte[]) TlvSerializer.deserialize(buf, BIN_IMAGE);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvSecurityKeySerializer.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.SecurityKey;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvSecurityKeySerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvSecurityKeySerializer.class);\n\n\tprivate TlvSecurityKeySerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, SecurityKey securityKey)\n\t{\n\t\tlog.trace(\"Writing TlvRsaKey\");\n\n\t\tvar len = getSize(securityKey);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(SECURITY_KEY.getValue());\n\t\tbuf.writeInt(len);\n\t\tTlvSerializer.serialize(buf, STR_KEY_ID, Id.toString(securityKey.getKeyGxsId()));\n\n\t\tSerializer.serialize(buf, securityKey.getFlags(), FieldSize.INTEGER);\n\t\tSerializer.serialize(buf, securityKey.getValidFromInTs());\n\t\tSerializer.serialize(buf, securityKey.getValidToInTs());\n\n\t\tTlvSerializer.serialize(buf, KEY_EVP_PKEY, securityKey.getData());\n\t\treturn len;\n\t}\n\n\tstatic int getSize(SecurityKey securityKey)\n\t{\n\t\treturn TLV_HEADER_SIZE\n\t\t\t\t+ (TLV_HEADER_SIZE + GxsId.LENGTH * 2)\n\t\t\t\t+ 4\n\t\t\t\t+ 4\n\t\t\t\t+ 4\n\t\t\t\t+ TLV_HEADER_SIZE + securityKey.getData().length;\n\t}\n\n\tstatic SecurityKey deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvRsaKey\");\n\n\t\tTlvUtils.checkTypeAndLength(buf, SECURITY_KEY);\n\t\tvar gxsId = new GxsId(Id.asciiStringToBytes((String) TlvSerializer.deserialize(buf, STR_KEY_ID)));\n\t\tvar flags = deserializeEnumSet(buf, SecurityKey.Flags.class, FieldSize.INTEGER);\n\t\tvar startTs = deserializeInt(buf);\n\t\tvar endTs = deserializeInt(buf);\n\n\t\tvar data = (byte[]) TlvSerializer.deserialize(buf, KEY_EVP_PKEY);\n\t\treturn new SecurityKey(gxsId, flags, startTs, endTs, data);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvSecurityKeySetSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.SecurityKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvSecurityKeySetSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvSecurityKeySetSerializer.class);\n\tprivate static final String GROUP_ID_VALUE = \"\"; // unused\n\n\tprivate TlvSecurityKeySetSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Set<SecurityKey> securityKeys)\n\t{\n\t\tlog.trace(\"Writing TlvSecurityKeySet\");\n\n\t\tvar len = getSize(securityKeys);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(SECURITY_KEY_SET.getValue());\n\t\tbuf.writeInt(len);\n\t\tTlvSerializer.serialize(buf, STR_GROUP_ID, GROUP_ID_VALUE);\n\t\tsecurityKeys.stream()\n\t\t\t\t.sorted()\n\t\t\t\t.forEach(securityKey -> TlvSerializer.serialize(buf, SECURITY_KEY, securityKey));\n\n\t\treturn len;\n\t}\n\n\tstatic int getSize(Set<SecurityKey> securityKeys)\n\t{\n\t\treturn TLV_HEADER_SIZE +\n\t\t\t\tTlvStringSerializer.getSize(GROUP_ID_VALUE) +\n\t\t\t\tsecurityKeys.stream().mapToInt(TlvSecurityKeySerializer::getSize).sum();\n\t}\n\n\tstatic Set<SecurityKey> deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvSecurityKeySet\");\n\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, SECURITY_KEY_SET);\n\n\t\t// STR_GROUP_ID must be empty\n\t\tif (!TlvSerializer.deserialize(buf, STR_GROUP_ID).equals(GROUP_ID_VALUE))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"STR_GROUP_ID is not empty\");\n\t\t}\n\t\tlen -= TlvStringSerializer.getSize(\"\");\n\n\t\tSet<SecurityKey> securityKeys = HashSet.newHashSet(2);\n\t\twhile (len > 0)\n\t\t{\n\t\t\tvar securityKey = (SecurityKey) TlvSerializer.deserialize(buf, SECURITY_KEY);\n\t\t\tsecurityKeys.add(securityKey);\n\t\t\tlen -= TlvSecurityKeySerializer.getSize(securityKey);\n\t\t}\n\t\treturn securityKeys;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.xrs.common.*;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.util.List;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.SIGNATURE_TYPE;\n\n/**\n * This class if for serializing/deserializing TLVs by:\n * <ul>\n * <li>{@code @RsSerialized} annotations</li>\n * <li>classes outside the {@code serialization} package</li>\n * </ul>\n * For anything else, use the TLV classes directly because they don't require casting of the\n * return types, and they have the {@code getSize()} method.\n */\nfinal class TlvSerializer\n{\n\tprivate TlvSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t@SuppressWarnings(\"unchecked\")\n\tstatic int serialize(ByteBuf buf, TlvType type, Object value)\n\t{\n\t\treturn switch (type)\n\t\t\t\t{\n\t\t\t\t\tcase STR_NONE, STR_NAME, STR_MSG, STR_LOCATION, STR_VERSION, STR_HASH_SHA1, STR_DYNDNS, STR_DOM_ADDR, STR_GENID, STR_KEY_ID, STR_GROUP_ID, STR_VALUE, STR_DESCR, STR_PATH, STR_LINK, STR_COMMENT, STR_TITLE, STR_GXS_MESSAGE_COMMENT -> TlvStringSerializer.serialize(buf, type, (String) value);\n\t\t\t\t\tcase INT_AGE, INT_POPULARITY, INT_SIZE, INT_BANDWIDTH -> TlvUint32Serializer.serialize(buf, type, (int) value);\n\t\t\t\t\tcase LONG_OFFSET -> TlvUint64Serializer.serialize(buf, type, (long) value);\n\t\t\t\t\tcase ADDRESS -> TlvAddressSerializer.serialize(buf, (PeerAddress) value);\n\t\t\t\t\tcase ADDRESS_SET -> TlvAddressSerializer.serializeList(buf, (List<PeerAddress>) value);\n\t\t\t\t\tcase SIGNATURE -> TlvSignatureSerializer.serialize(buf, (Signature) value);\n\t\t\t\t\tcase SET_PGP_ID -> TlvSetSerializer.serializeLong(buf, type, (Set<Long>) value);\n\t\t\t\t\tcase SET_HASH, SET_GXS_ID, SET_GXS_MSG_ID -> TlvSetSerializer.serializeIdentifier(buf, type, (Set<? extends Identifier>) value);\n\t\t\t\t\tcase SET_RECOGN -> TlvStringSetRefSerializer.serialize(buf, type, (List<String>) value);\n\t\t\t\t\tcase SIGNATURE_SET -> TlvSignatureSetSerializer.serialize(buf, (Set<Signature>) value);\n\t\t\t\t\tcase SIGNATURE_TYPE -> TlvUint32Serializer.serialize(buf, SIGNATURE_TYPE, (int) value);\n\t\t\t\t\tcase SECURITY_KEY -> TlvSecurityKeySerializer.serialize(buf, (SecurityKey) value);\n\t\t\t\t\tcase SECURITY_KEY_SET -> TlvSecurityKeySetSerializer.serialize(buf, (Set<SecurityKey>) value);\n\t\t\t\t\tcase IMAGE -> TlvImageSerializer.serialize(buf, (byte[]) value);\n\t\t\t\t\tcase FILE_SET -> TlvFileSetSerializer.serialize(buf, (FileSet) value);\n\t\t\t\t\tcase FILE_ITEM -> TlvFileItemSerializer.serialize(buf, (FileItem) value);\n\t\t\t\t\tcase FILE_DATA -> TlvFileDataSerializer.serialize(buf, (FileData) value);\n\t\t\t\t\tcase SIGN_RSA_SHA1, KEY_EVP_PKEY, STR_SIGN, BIN_IMAGE, BIN_FILE_DATA -> TlvBinarySerializer.serialize(buf, type, (byte[]) value);\n\t\t\t\t\tcase IPV4, IPV6, ADDRESS_INFO, UNKNOWN -> throw new IllegalArgumentException(\"Can't use type \" + type + \" for direct TLV serialization\");\n\t\t\t\t};\n\t}\n\n\tstatic Object deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\treturn switch (type)\n\t\t\t\t{\n\t\t\t\t\tcase STR_NONE, STR_NAME, STR_MSG, STR_LOCATION, STR_VERSION, STR_HASH_SHA1, STR_DYNDNS, STR_DOM_ADDR, STR_GENID, STR_KEY_ID, STR_GROUP_ID, STR_VALUE, STR_DESCR, STR_PATH, STR_LINK, STR_COMMENT, STR_TITLE, STR_GXS_MESSAGE_COMMENT -> TlvStringSerializer.deserialize(buf, type);\n\t\t\t\t\tcase INT_AGE, INT_POPULARITY, INT_SIZE, INT_BANDWIDTH -> TlvUint32Serializer.deserialize(buf, type);\n\t\t\t\t\tcase LONG_OFFSET -> TlvUint64Serializer.deserialize(buf, type);\n\t\t\t\t\tcase ADDRESS -> TlvAddressSerializer.deserialize(buf);\n\t\t\t\t\tcase ADDRESS_SET -> TlvAddressSerializer.deserializeList(buf);\n\t\t\t\t\tcase SIGNATURE -> TlvSignatureSerializer.deserialize(buf);\n\t\t\t\t\tcase SET_PGP_ID -> TlvSetSerializer.deserializeLong(buf, type);\n\t\t\t\t\tcase SET_HASH -> TlvSetSerializer.deserializeIdentifier(buf, type, Sha1Sum.class);\n\t\t\t\t\tcase SET_GXS_ID -> TlvSetSerializer.deserializeIdentifier(buf, type, GxsId.class);\n\t\t\t\t\tcase SET_GXS_MSG_ID -> TlvSetSerializer.deserializeIdentifier(buf, type, MsgId.class);\n\t\t\t\t\tcase SET_RECOGN -> TlvStringSetRefSerializer.deserialize(buf, type);\n\t\t\t\t\tcase SIGNATURE_SET -> TlvSignatureSetSerializer.deserialize(buf);\n\t\t\t\t\tcase SIGNATURE_TYPE -> TlvUint32Serializer.deserialize(buf, SIGNATURE_TYPE);\n\t\t\t\t\tcase SECURITY_KEY -> TlvSecurityKeySerializer.deserialize(buf);\n\t\t\t\t\tcase SECURITY_KEY_SET -> TlvSecurityKeySetSerializer.deserialize(buf);\n\t\t\t\t\tcase IMAGE -> TlvImageSerializer.deserialize(buf);\n\t\t\t\t\tcase FILE_SET -> TlvFileSetSerializer.deserialize(buf);\n\t\t\t\t\tcase FILE_ITEM -> TlvFileItemSerializer.deserialize(buf);\n\t\t\t\t\tcase FILE_DATA -> TlvFileDataSerializer.deserialize(buf);\n\t\t\t\t\tcase SIGN_RSA_SHA1, KEY_EVP_PKEY, STR_SIGN, BIN_IMAGE, BIN_FILE_DATA -> TlvBinarySerializer.deserialize(buf, type);\n\t\t\t\t\tcase IPV4, IPV6, ADDRESS_INFO, UNKNOWN -> throw new IllegalArgumentException(\"Can't use type \" + type + \" for direct TLV deserialization\");\n\t\t\t\t};\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvSetSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.common.id.Identifier;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.math.BigInteger;\nimport java.util.Arrays;\nimport java.util.Comparator;\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvSetSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvSetSerializer.class);\n\n\tprivate TlvSetSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serializeLong(ByteBuf buf, TlvType type, Set<Long> set)\n\t{\n\t\tvar len = getLongSize(set);\n\t\tlog.trace(\"Writing set of longs: {}\", log.isTraceEnabled() ? Arrays.toString(set.toArray()) : \"\");\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type.getValue());\n\t\tbuf.writeInt(len);\n\t\tset.stream()\n\t\t\t\t.sorted()\n\t\t\t\t.forEach(buf::writeLong);\n\t\treturn len;\n\t}\n\n\tstatic int serializeIdentifier(ByteBuf buf, TlvType type, Set<? extends Identifier> set)\n\t{\n\t\tvar len = getIdentifierSize(set);\n\t\tlog.trace(\"Writing set of identifiers: {}\", log.isTraceEnabled() ? Arrays.toString(set.toArray()) : \"\");\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type.getValue());\n\t\tbuf.writeInt(len);\n\t\tset.stream()\n\t\t\t\t.sorted(Comparator.comparing(identifier -> new BigInteger(1, identifier.getBytes())))\n\t\t\t\t.forEach(identifier -> buf.writeBytes(identifier.getBytes()));\n\t\treturn len;\n\t}\n\n\tprivate static int getLongSize(Set<Long> set)\n\t{\n\t\treturn TLV_HEADER_SIZE + Long.BYTES * set.size();\n\t}\n\n\tstatic int getIdentifierSize(Set<? extends Identifier> set)\n\t{\n\t\tif (set.isEmpty())\n\t\t{\n\t\t\treturn TLV_HEADER_SIZE;\n\t\t}\n\t\treturn TLV_HEADER_SIZE + set.stream().findFirst().orElseThrow().getLength() * set.size();\n\t}\n\n\tstatic Set<Long> deserializeLong(ByteBuf buf, TlvType type)\n\t{\n\t\tlog.trace(\"Reading set of longs\");\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, type);\n\t\tvar count = len / Long.BYTES;\n\t\tHashSet<Long> set = HashSet.newHashSet(count);\n\n\t\twhile (count-- > 0)\n\t\t{\n\t\t\tset.add(buf.readLong());\n\t\t}\n\t\treturn set;\n\t}\n\n\tstatic Set<? extends Identifier> deserializeIdentifier(ByteBuf buf, TlvType type, Class<?> identifierClass)\n\t{\n\t\tlog.trace(\"Reading set of identifiers\");\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, type);\n\t\tvar count = len / IdentifierSerializer.getIdentifierLength(identifierClass);\n\t\tHashSet<Identifier> set = HashSet.newHashSet(count);\n\n\t\twhile (count-- > 0)\n\t\t{\n\t\t\tset.add(IdentifierSerializer.deserialize(buf, identifierClass));\n\t\t}\n\t\treturn set;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvSignatureSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvSignatureSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvSignatureSerializer.class);\n\n\tprivate TlvSignatureSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Signature signature)\n\t{\n\t\tlog.trace(\"Writing TlvKeySignature\");\n\n\t\tvar len = getSize(signature);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(SIGNATURE.getValue());\n\t\tbuf.writeInt(len);\n\t\tTlvSerializer.serialize(buf, STR_KEY_ID, Id.toString(signature.getGxsId()));\n\t\tTlvSerializer.serialize(buf, SIGN_RSA_SHA1, signature.getData());\n\n\t\treturn len;\n\t}\n\n\tstatic int getSize(Signature signature)\n\t{\n\t\treturn TLV_HEADER_SIZE +\n\t\t\t\t(TLV_HEADER_SIZE + GxsId.LENGTH * 2) +\n\t\t\t\tTlvBinarySerializer.getSize(signature.getData());\n\t}\n\n\tstatic Signature deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvKeySignature\");\n\n\t\tTlvUtils.checkTypeAndLength(buf, SIGNATURE);\n\t\tvar gxsId = new GxsId(Id.asciiStringToBytes((String) TlvSerializer.deserialize(buf, STR_KEY_ID)));\n\t\tvar data = (byte[]) TlvSerializer.deserialize(buf, SIGN_RSA_SHA1);\n\t\treturn new Signature(gxsId, data);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvSignatureSetSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.Signature;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\nfinal class TlvSignatureSetSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvSignatureSetSerializer.class);\n\n\tprivate TlvSignatureSetSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, Set<Signature> signatures)\n\t{\n\t\tlog.trace(\"Writing TlvSignatureSet\");\n\n\t\tvar len = getSize(signatures);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(SIGNATURE_SET.getValue());\n\t\tbuf.writeInt(len);\n\t\tsignatures.stream()\n\t\t\t\t.sorted()\n\t\t\t\t.forEach(signature -> {\n\t\t\t\t\tTlvSerializer.serialize(buf, SIGNATURE_TYPE, signature.getType().getValue());\n\t\t\t\t\tTlvSerializer.serialize(buf, SIGNATURE, signature);\n\t\t\t\t});\n\n\t\treturn len;\n\t}\n\n\tstatic int getSize(Set<Signature> signatures)\n\t{\n\t\treturn TLV_HEADER_SIZE +\n\t\t\t\tsignatures.stream().mapToInt(signature -> TlvUint32Serializer.getSize() + TlvSignatureSerializer.getSize(signature)).sum();\n\t}\n\n\tstatic Set<Signature> deserialize(ByteBuf buf)\n\t{\n\t\tlog.trace(\"Reading TlvSignatureSet\");\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, SIGNATURE_SET);\n\n\t\tSet<Signature> signatures = HashSet.newHashSet(2);\n\n\t\twhile (len > 0)\n\t\t{\n\t\t\tvar type = Signature.Type.findByValue((int) TlvSerializer.deserialize(buf, SIGNATURE_TYPE));\n\t\t\tvar signature = (Signature) TlvSerializer.deserialize(buf, SIGNATURE);\n\t\t\tsignature.setType(type);\n\t\t\tsignatures.add(signature);\n\t\t\tlen -= TlvUint32Serializer.getSize() + TlvSignatureSerializer.getSize(signature);\n\t\t}\n\t\treturn signatures;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvStringSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvStringSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvStringSerializer.class);\n\n\tprivate TlvStringSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, TlvType type, String s)\n\t{\n\t\tvar len = getSize(s);\n\n\t\tvar bytes = s != null ? s.getBytes() : new byte[0];\n\n\t\tlog.trace(\"Writing string ({}): \\\"{}\\\"\", type, s);\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type.getValue());\n\t\tbuf.writeInt(len);\n\t\tif (bytes.length > 0)\n\t\t{\n\t\t\tbuf.writeBytes(bytes);\n\t\t}\n\t\treturn len;\n\t}\n\n\tstatic int getSize(String s)\n\t{\n\t\treturn TLV_HEADER_SIZE + (s != null ? s.getBytes().length : 0);\n\t}\n\n\tstatic String deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\tlog.trace(\"Reading TLV string\");\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, type);\n\t\tvar out = new byte[len];\n\t\tbuf.readBytes(out);\n\t\treturn new String(out);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvStringSetRefSerializer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvStringSetRefSerializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TlvStringSetRefSerializer.class);\n\n\tprivate TlvStringSetRefSerializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t// XXX: warning! serialization has not been tested\n\tstatic int serialize(ByteBuf buf, TlvType type, List<String> refIds)\n\t{\n\t\tvar len = getSize(refIds);\n\t\tlog.trace(\"Writing refids: {}\", log.isTraceEnabled() ? refIds : \"\");\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type.getValue());\n\t\tbuf.writeInt(len);\n\t\trefIds.forEach(s -> TlvSerializer.serialize(buf, TlvType.STR_GENID, s));\n\t\treturn len;\n\t}\n\n\tstatic int getSize(List<String> refIds)\n\t{\n\t\treturn TLV_HEADER_SIZE + refIds.size();\n\t}\n\n\tstatic List<String> deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\tlog.trace(\"Reading refids\");\n\t\tvar len = TlvUtils.checkTypeAndLength(buf, type);\n\t\tvar listIndex = buf.readerIndex();\n\t\tList<String> refIds = new ArrayList<>();\n\t\twhile (buf.readerIndex() < listIndex + len)\n\t\t{\n\t\t\trefIds.add((String) Serializer.deserialize(buf, TlvType.STR_GENID));\n\t\t}\n\t\treturn refIds;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvType.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\npublic enum TlvType\n{\n\tSTR_NONE(0x0), // Used to write strings without TLVs\n\tSTR_GXS_MESSAGE_COMMENT(0x1), // Used only by GxS comment messages\n\tINT_SIZE(0x30),\n\tINT_POPULARITY(0x31),\n\tINT_AGE(0x32),\n\tINT_BANDWIDTH(0x35),\n\tLONG_OFFSET(0x41),\n\tSTR_NAME(0x51),\n\tSTR_PATH(0x52),\n\tSTR_VALUE(0x54),\n\tSTR_COMMENT(0x55),\n\tSTR_TITLE(0x56),\n\tSTR_MSG(0x57),\n\tSTR_LINK(0x59),\n\tSTR_GENID(0x5a),\n\tSTR_LOCATION(0x5c),\n\tSTR_VERSION(0x5f),\n\tSTR_HASH_SHA1(0x70),\n\tSTR_DYNDNS(0x83),\n\tSTR_DOM_ADDR(0x84),\n\tIPV4(0x85),\n\tIPV6(0x86),\n\tSTR_GROUP_ID(0xa0),\n\tSTR_KEY_ID(0xa4),\n\tSTR_DESCR(0xb3),\n\tSTR_SIGN(0xb4),\n\tKEY_EVP_PKEY(0x110),\n\tSIGN_RSA_SHA1(0x120),\n\tBIN_IMAGE(0x130),\n\tBIN_FILE_DATA(0x140),\n\tFILE_ITEM(0x1000),\n\tFILE_SET(0x1001),\n\tFILE_DATA(0x1002),\n\tSET_HASH(0x1022),\n\tSET_PGP_ID(0x1023),\n\tSET_RECOGN(0x1024),\n\tSET_GXS_ID(0x1025),\n\tSET_GXS_MSG_ID(0x1028),\n\tSECURITY_KEY(0x1040),\n\tSECURITY_KEY_SET(0x1041),\n\tSIGNATURE(0x1050),\n\tSIGNATURE_SET(0x1051),\n\tSIGNATURE_TYPE(0x1052),\n\tIMAGE(0x1060),\n\tADDRESS_INFO(0x1070),\n\tADDRESS_SET(0x1071),\n\tADDRESS(0x1072),\n\tUNKNOWN(0xffff); // Used to signal that an unknown TLV has been found\n\n\tprivate final int value;\n\n\tTlvType(int value)\n\t{\n\t\tthis.value = value;\n\t}\n\n\tpublic int getValue()\n\t{\n\t\treturn value;\n\t}\n\n\t/**\n\t * Gets a TLV from the value.\n\t *\n\t * @param value the TLV value\n\t * @return the TLV or UNKNOWN if the value is not known (including for NONE and UNKNOWN itself)\n\t */\n\tpublic static TlvType fromValue(int value)\n\t{\n\t\tfor (TlvType tlvType : values())\n\t\t{\n\t\t\tif (tlvType.getValue() == value && tlvType != STR_NONE)\n\t\t\t{\n\t\t\t\treturn tlvType;\n\t\t\t}\n\n\t\t}\n\t\treturn UNKNOWN;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvUint32Serializer.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvUint32Serializer\n{\n\tprivate TlvUint32Serializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, TlvType type, int value)\n\t{\n\t\tvar len = getSize();\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type.getValue());\n\t\tbuf.writeInt(len);\n\t\tbuf.writeInt(value);\n\t\treturn len;\n\t}\n\n\tstatic int getSize()\n\t{\n\t\treturn TLV_HEADER_SIZE + Integer.BYTES;\n\t}\n\n\tstatic int deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\tvar readType = buf.readUnsignedShort();\n\t\tif (readType != type.getValue())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Type \" + readType + \" does not match \" + type);\n\t\t}\n\t\tvar len = buf.readInt();\n\t\tif (len != getSize())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Length is wrong: \" + len + \", expected: \" + getSize());\n\t\t}\n\t\treturn buf.readInt();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvUint64Serializer.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvUint64Serializer\n{\n\tprivate TlvUint64Serializer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic int serialize(ByteBuf buf, TlvType type, long value)\n\t{\n\t\tvar len = getSize();\n\t\tbuf.ensureWritable(len);\n\t\tbuf.writeShort(type.getValue());\n\t\tbuf.writeInt(len);\n\t\tbuf.writeLong(value);\n\t\treturn len;\n\t}\n\n\tstatic int getSize()\n\t{\n\t\treturn TLV_HEADER_SIZE + Long.BYTES;\n\t}\n\n\tstatic long deserialize(ByteBuf buf, TlvType type)\n\t{\n\t\tvar readType = buf.readUnsignedShort();\n\t\tif (readType != type.getValue())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Type \" + readType + \" does not match \" + type);\n\t\t}\n\t\tvar len = buf.readInt();\n\t\tif (len != getSize())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Length is wrong: \" + len + \", expected: \" + getSize());\n\t\t}\n\t\treturn buf.readLong();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/serialization/TlvUtils.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.ByteBuf;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\n\nfinal class TlvUtils\n{\n\tprivate TlvUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Checks if the buffer contains the right TlvType and if the length is at least TLV_HEADER_SIZE.\n\t *\n\t * @param buf     the ByteBuf containing the incoming data\n\t * @param tlvType the TlvType to check against\n\t * @return the remaining length after TLV_HEADER_SIZE is subtracted\n\t */\n\tstatic int checkTypeAndLength(ByteBuf buf, TlvType tlvType)\n\t{\n\t\treturn checkTypeAndLength(buf, tlvType.getValue());\n\t}\n\n\t/**\n\t * Checks if the buffer contains the right TLV type and if the length is at least TLV_HEADER_SIZE.\n\t * This function is needed in addition to the one above because Retroshare abuses some TLVs to store the service type in them.\n\t *\n\t * @param buf     the ByteBuf containing the incoming data\n\t * @param tlvType the TLV type to check against, as an int\n\t * @return the remaining length after TLV_HEADER_SIZE is subtracted\n\t */\n\tstatic int checkTypeAndLength(ByteBuf buf, int tlvType)\n\t{\n\t\tvar readType = buf.readUnsignedShort();\n\t\tif (readType != tlvType)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Type \" + readType + \" does not match \" + tlvType);\n\t\t}\n\t\tvar len = buf.readInt();\n\t\tif (len < TLV_HEADER_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Length \" + len + \" is smaller than the header size (6)\");\n\t\t}\n\t\treturn len - TLV_HEADER_SIZE;\n\t}\n\n\t/**\n\t * Checks the next buffer to get the TLV type.\n\t *\n\t * @param buf the ByteBuf containing the incoming data\n\t * @return the TLV type or null if not found or if the buffer is empty\n\t */\n\tstatic TlvType peekTlvType(ByteBuf buf)\n\t{\n\t\tif (buf.readableBytes() < TLV_HEADER_SIZE)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn TlvType.fromValue(buf.getUnsignedShort(buf.readerIndex()));\n\t}\n\n\t/**\n\t * Skips the TLV.\n\t *\n\t * @param buf the ByteBuf containing the TLV\n\t */\n\tstatic void skipTlv(ByteBuf buf)\n\t{\n\t\tif (buf.readableBytes() < TLV_HEADER_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Can't skip the TLV because there's not enough bytes to represent one\");\n\t\t}\n\t\tbuf.readUnsignedShort();\n\t\tvar size = buf.readInt();\n\t\tbuf.skipBytes(size);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/DefaultItem.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\nimport io.xeres.app.xrs.item.Item;\n\n/**\n * An item that is not part of any service.\n * Is used when there's no service that maps to an item.\n * Will just be disposed by the pipeline.\n */\npublic final class DefaultItem extends Item\n{\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn io.xeres.common.protocol.xrs.RsServiceType.NONE.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 0;\n\t}\n\n\t@Override\n\tpublic DefaultItem clone()\n\t{\n\t\treturn (DefaultItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"DefaultItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/RsService.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\nimport io.xeres.app.application.events.NetworkReadyEvent;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.xrs.item.Item;\nimport jakarta.annotation.PostConstruct;\nimport jakarta.annotation.PreDestroy;\nimport org.springframework.context.annotation.DependsOn;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\n\n/**\n * Base class for \"Retroshare services\".\n * These services have a unique number assigned which directs matching packets to them.\n * <p>\n * Note: this class has a natural ordering that is inconsistent with equals.\n */\n@DependsOn({\"rsServiceRegistry\"})\npublic abstract class RsService implements Comparable<RsService>\n{\n\tpublic abstract io.xeres.common.protocol.xrs.RsServiceType getServiceType();\n\n\t/**\n\t * Handle incoming items. You can use JPA calls in there if your implementation is annotated with @Transactional.\n\t *\n\t * @param sender the peer sending the item\n\t * @param item   the item\n\t */\n\tpublic abstract void handleItem(PeerConnection sender, Item item);\n\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\tprivate boolean enabled;\n\tprivate boolean initialized;\n\n\tprotected RsService(RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t}\n\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.OFF;\n\t}\n\n\t/**\n\t * Sent once upon startup when the service is enabled and the network is ready. Good place to initialize\n\t * executors, etc...\n\t * <p>\n\t * Keep in mind that your service can receive some packets before initialize() is called.\n\t */\n\tpublic void initialize()\n\t{\n\t\t// Do nothing by default\n\t}\n\n\t/**\n\t * Sent once when the application is exiting but before closing the connections.\n\t * Good place to send last messages (for example, leaving a room, etc...).\n\t */\n\tpublic void shutdown(PeerConnection peerConnection)\n\t{\n\t\t// Do nothing by default\n\t}\n\n\t/**\n\t * Sent once when the application is exiting. Good place to perform spring boot related cleanups\n\t * since the beans are all still available.\n\t */\n\tpublic void shutdown()\n\t{\n\t\t// Do nothing by default\n\t}\n\n\t/**\n\t * Sent once when the application is almost done exiting. Good place to remove any\n\t * executor setup in initialize().\n\t */\n\tpublic void cleanup()\n\t{\n\t\t// Do nothing by default\n\t}\n\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tthrow new IllegalStateException(\"Implement initialize() method if you override getInitPriority() to be anything else than OFF\");\n\t}\n\n\t@PostConstruct\n\tprivate void init()\n\t{\n\t\tenabled = rsServiceRegistry.registerService(this);\n\t}\n\n\t@EventListener\n\tpublic void init(NetworkReadyEvent unused)\n\t{\n\t\tif (enabled && !initialized)\n\t\t{\n\t\t\tinitialized = true;\n\t\t\tinitialize();\n\t\t\taddSlavesIfNeeded();\n\t\t}\n\t}\n\n\tprivate void addSlavesIfNeeded()\n\t{\n\t\tif (RsServiceMaster.class.isAssignableFrom(getClass()))\n\t\t{\n\t\t\t//noinspection rawtypes,unchecked\n\t\t\trsServiceRegistry.getSlaves(this).forEach(rsServiceSlave -> ((RsServiceMaster) this).addRsSlave(rsServiceSlave));\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent unused)\n\t{\n\t\tif (enabled)\n\t\t{\n\t\t\tshutdown();\n\t\t}\n\t}\n\n\t@PreDestroy\n\tprivate void destroy()\n\t{\n\t\tif (enabled)\n\t\t{\n\t\t\tcleanup();\n\t\t}\n\t}\n\n\t@Override\n\t@SuppressWarnings(\"java:S1210\")\n\tpublic int compareTo(RsService o)\n\t{\n\t\treturn Integer.compare(getInitPriority().ordinal(), o.getInitPriority().ordinal());\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn getServiceType().getName();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/RsServiceInitPriority.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\n/**\n * Priority for running service initializations. Except when OFF (default),\n * contains a time range with random triggering in between, to increase handshake\n * chances between peers.\n */\npublic enum RsServiceInitPriority\n{\n\tOFF(0, 0),\n\tLOW(11, 20),\n\tNORMAL(6, 10),\n\tHIGH(3, 5),\n\tIMMEDIATE(1, 2);\n\n\tprivate final int minTime;\n\tprivate final int maxTime;\n\n\tRsServiceInitPriority(int minTime, int maxTime)\n\t{\n\t\tthis.minTime = minTime;\n\t\tthis.maxTime = maxTime;\n\t}\n\n\tpublic int getMinTime()\n\t{\n\t\treturn minTime;\n\t}\n\n\tpublic int getMaxTime()\n\t{\n\t\treturn maxTime;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/RsServiceMaster.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\n/**\n * This interface allows to implement dependencies between services, that is, one master service\n * has a list of clients that it can handle. Each master service or client needs to implement this interface\n * which can of course be extended.\n *\n * @see RsServiceSlave\n */\npublic interface RsServiceMaster<T>\n{\n\t/**\n\t * Adds a slave service to a master service. The master service is responsible to handle them.\n\t *\n\t * @param slave the slave service to add to the master\n\t */\n\tvoid addRsSlave(T slave);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/RsServiceRegistry.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.RawItem;\nimport io.xeres.app.xrs.service.gxs.GxsRsService;\nimport io.xeres.app.xrs.service.gxs.item.DynamicServiceType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.beans.factory.config.BeanDefinition;\nimport org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;\nimport org.springframework.core.env.Environment;\nimport org.springframework.core.type.filter.AssignableTypeFilter;\nimport org.springframework.stereotype.Component;\n\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.*;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\n@Component\npublic class RsServiceRegistry\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RsServiceRegistry.class);\n\tprivate static final String SERVICE_PACKAGE = \"io.xeres.app.xrs.service\";\n\tprivate static final String RS_SERVICE_CLASS_SUFFIX = \"RsService\";\n\n\tprivate final Set<String> enabledServiceClasses = new HashSet<>();\n\tprivate final Map<Integer, RsService> services = new HashMap<>();\n\tprivate final Map<Integer, List<RsServiceSlave>> masterServices = new HashMap<>();\n\n\tprivate final Map<Integer, Map<Integer, Class<? extends Item>>> itemClassesWaiting = new HashMap<>();\n\tprivate final Map<Integer, Class<? extends Item>> itemClassesGxsWaiting = new HashMap<>();\n\tprivate final Map<Integer, Class<? extends Item>> itemClasses = new HashMap<>();\n\n\tpublic RsServiceRegistry(Environment environment)\n\t{\n\t\tvar provider = new ClassPathScanningCandidateComponentProvider(false);\n\t\tprovider.addIncludeFilter(new AssignableTypeFilter(RsService.class));\n\t\tvar scannedServiceClasses = provider.findCandidateComponents(SERVICE_PACKAGE);\n\n\t\tprovider.resetFilters(false);\n\t\tprovider.addIncludeFilter(new AssignableTypeFilter(Item.class));\n\t\tvar scannedItemClasses = provider.findCandidateComponents(SERVICE_PACKAGE);\n\n\t\tregisterServices(environment, scannedServiceClasses);\n\t\tregisterItems(scannedItemClasses);\n\t}\n\n\t/**\n\t * Records which services are enabled in the properties file.\n\t *\n\t * @param environment           the environment\n\t * @param scannedServiceClasses the service classes\n\t */\n\tprivate void registerServices(Environment environment, Set<BeanDefinition> scannedServiceClasses)\n\t{\n\t\tfor (var bean : scannedServiceClasses)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\t\tvar serviceClass = (Class<? extends RsService>) Class.forName(bean.getBeanClassName());\n\t\t\t\tvar serviceName = serviceClass.getSimpleName();\n\t\t\t\tvar propertyName = \"xrs.service.\" + serviceName.substring(0, serviceName.length() - RS_SERVICE_CLASS_SUFFIX.length()).toLowerCase(Locale.ROOT) + \".enabled\";\n\t\t\t\tif (environment.getProperty(propertyName, Boolean.class, false))\n\t\t\t\t{\n\t\t\t\t\tenabledServiceClasses.add(serviceName);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (ClassNotFoundException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Adds all item classes, they will be enabled later when the service is confirmed to be enabled\n\t *\n\t * @param scannedItemClasses the item classes\n\t */\n\tprivate void registerItems(Set<BeanDefinition> scannedItemClasses)\n\t{\n\t\tfor (var bean : scannedItemClasses)\n\t\t{\n\t\t\tClass<? extends Item> itemClass = null;\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\t//noinspection unchecked\n\t\t\t\titemClass = (Class<? extends Item>) Class.forName(bean.getBeanClassName());\n\n\t\t\t\tvar item = (Item) itemClass.getConstructor().newInstance();\n\n\t\t\t\t//noinspection StatementWithEmptyBody\n\t\t\t\tif (GxsGroupItem.class.isAssignableFrom(itemClass) || GxsMessageItem.class.isAssignableFrom(itemClass))\n\t\t\t\t{\n\t\t\t\t\t// For GxsGroup and GxsMessage items, we ignore them because they can only be received within transactions\n\t\t\t\t\t// (but the real reason is that their subtype clashes with GxsExchange subtypes)\n\t\t\t\t}\n\t\t\t\telse if (DynamicServiceType.class.isAssignableFrom(itemClass))\n\t\t\t\t{\n\t\t\t\t\t// For DynamicServiceType (mostly GxsExchange) items, we don't know their ServiceType yet because they are shared.\n\t\t\t\t\titemClassesGxsWaiting.put(item.getSubType(), itemClass);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\titemClassesWaiting.computeIfAbsent(item.getServiceType(), v -> new HashMap<>()).put(item.getSubType(), itemClass);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException | ClassNotFoundException e)\n\t\t\t{\n\t\t\t\tif (itemClass != null)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(itemClass.getSimpleName() + \" requires a public constructor with no parameters\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tthrow new RuntimeException(e);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic List<RsService> getServices()\n\t{\n\t\treturn new ArrayList<>(services.values());\n\t}\n\n\tpublic RsService getServiceFromType(int type)\n\t{\n\t\treturn services.get(type);\n\t}\n\n\tpublic boolean registerService(RsService rsService)\n\t{\n\t\tvar serviceType = rsService.getServiceType().getType();\n\n\t\tif (!enabledServiceClasses.contains(rsService.getClass().getSimpleName()))\n\t\t{\n\t\t\treturn false; // the service is disabled\n\t\t}\n\n\t\tservices.put(serviceType, rsService);\n\n\t\tif (RsServiceSlave.class.isAssignableFrom(rsService.getClass()))\n\t\t{\n\t\t\tvar master = ((RsServiceSlave) rsService).getMasterServiceType();\n\n\t\t\tmasterServices.computeIfAbsent(master.getType(), v -> new ArrayList<>()).add((RsServiceSlave) rsService);\n\t\t}\n\n\t\tif (GxsRsService.class.isAssignableFrom(rsService.getClass()))\n\t\t{\n\t\t\titemClassesGxsWaiting.forEach((subType, itemClass) -> itemClasses.put(serviceType << 16 | subType, itemClass));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar itemClassMap = itemClassesWaiting.remove(serviceType);\n\t\t\tif (itemClassMap != null)\n\t\t\t{\n\t\t\t\titemClassMap.forEach((subType, itemClass) -> itemClasses.put(serviceType << 16 | subType, itemClass));\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\tList<RsServiceSlave> getSlaves(RsService rsService)\n\t{\n\t\tif (!RsServiceMaster.class.isAssignableFrom(rsService.getClass()))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Master service \" + rsService + \" doesn't implement RsServiceMaster interface\");\n\t\t}\n\t\treturn emptyIfNull(masterServices.get(rsService.getServiceType().getType()));\n\t}\n\n\t/**\n\t * Builds an item.\n\t *\n\t * @param rawItem the {@link RawItem} to deserialize from\n\t * @return the {@link Item}\n\t * @see io.xeres.app.xrs.serialization.Serializer Serializer\n\t */\n\tpublic Item buildIncomingItem(RawItem rawItem)\n\t{\n\t\tvar version = rawItem.getPacketVersion();\n\t\tvar service = rawItem.getPacketService();\n\t\tvar subType = rawItem.getPacketSubType();\n\n\t\tif (version == 2)\n\t\t{\n\t\t\tvar itemClass = itemClasses.get(service << 16 | subType);\n\t\t\tif (itemClass != null)\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tvar item = itemClass.getConstructor().newInstance();\n\t\t\t\t\tif (DynamicServiceType.class.isAssignableFrom(item.getClass()))\n\t\t\t\t\t{\n\t\t\t\t\t\t((DynamicServiceType) item).setServiceType(service);\n\t\t\t\t\t}\n\t\t\t\t\treturn item;\n\t\t\t\t}\n\t\t\t\tcatch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Couldn't create item: {}\", e.getMessage());\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Couldn't create item (service: {}, subtype: {}): no mapping found\", service, subType);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.warn(\"Packet version {} is not supported\", version);\n\t\t}\n\t\treturn new DefaultItem(); // will just get disposed\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/RsServiceSlave.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\n/**\n * This interface allows to mark a service as a slave of some master.\n *\n * @see RsServiceMaster\n */\npublic interface RsServiceSlave\n{\n\t/**\n\t * Registers this service as a slave of another service.\n\t *\n\t * @return the master service this service is slave of\n\t */\n\tio.xeres.common.protocol.xrs.RsServiceType getMasterServiceType();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/bandwidth/BandwidthRsService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.bandwidth;\n\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.bandwidth.item.BandwidthAllowedItem;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.rest.statistics.DataCounterPeer;\nimport io.xeres.common.rest.statistics.DataCounterStatisticsResponse;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.app.net.peer.PeerConnection.KEY_BANDWIDTH;\nimport static io.xeres.common.protocol.xrs.RsServiceType.BANDWIDTH_CONTROL;\n\n@Component\npublic class BandwidthRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(BandwidthRsService.class);\n\n\tprivate static final double BANDWIDTH_UTILIZATION = 0.75;\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate long currentBandwidth;\n\n\tBandwidthRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn BANDWIDTH_CONTROL;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.HIGH;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tThread.ofVirtual().name(\"Bandwidth Finder\").start(() -> {\n\t\t\tcurrentBandwidth = BandwidthUtils.findBandwidth();\n\t\t\tlog.info(\"Found bandwidth of {} bps\", currentBandwidth);\n\t\t});\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.schedule(\n\t\t\t\t() -> sendBandwidthCapabilities(peerConnection)\n\t\t\t\t, 10,\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\tprivate void sendBandwidthCapabilities(PeerConnection peerConnection)\n\t{\n\t\tif (currentBandwidth != 0L)\n\t\t{\n\t\t\tlog.debug(\"Sending Bandwidth of {} bit/s to peer {}\", currentBandwidth, peerConnection);\n\t\t\tpeerConnectionManager.writeItem(peerConnection, new BandwidthAllowedItem((long) (currentBandwidth * BANDWIDTH_UTILIZATION / 8)), this); // RS wants bytes/s, and it defaults to 75% of the bandwidth\n\t\t}\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof BandwidthAllowedItem bandwidthAllowedItem)\n\t\t{\n\t\t\tlog.debug(\"Allowed bandwidth for peer {}: {} bytes/s\", sender, bandwidthAllowedItem.getAllowedBandwidth());\n\t\t\tsender.putPeerData(KEY_BANDWIDTH, bandwidthAllowedItem.getAllowedBandwidth());\n\t\t}\n\t}\n\n\t@Transactional(readOnly = true)\n\tpublic DataCounterStatisticsResponse getDataCounterStatistics()\n\t{\n\t\tList<DataCounterPeer> peers = new ArrayList<>();\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> peers.add(new DataCounterPeer(peerConnection.getLocation().getId(),\n\t\t\t\tpeerConnection.getLocation().getProfile().getName() + \"@\" + peerConnection.getLocation().getSafeName(),\n\t\t\t\tpeerConnection.getSentCounter(),\n\t\t\t\tpeerConnection.getReceivedCounter())), null);\n\t\treturn new DataCounterStatisticsResponse(peers);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/bandwidth/BandwidthUtils.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.bandwidth;\n\nimport io.xeres.common.util.OsUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.Comparator;\nimport java.util.regex.Pattern;\n\nfinal class BandwidthUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(BandwidthUtils.class);\n\n\tprivate static final Pattern LINUX_BANDWIDTH_PATTERN = Pattern.compile(\"\\\\d+.\", Pattern.DOTALL);\n\tprivate static final Pattern MACOS_BANDWIDTH_PATTERN = Pattern.compile(\"(\\\\d+)baseT\");\n\n\tprivate BandwidthUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/// Tries to find the maximum bandwidth of a host.\n\t///\n\t/// Note: this doesn't take into account any possible router on the LAN.\n\t///\n\t/// @return the maximum bandwidth in bps, or 0 if not found or not available\n\tpublic static long findBandwidth()\n\t{\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\tvar result = OsUtils.shellExecute(\"powershell.exe\", \"-Command\", \"(Get-Counter '\\\\Network Interface(*)\\\\Current Bandwidth').CounterSamples.CookedValue\");\n\t\t\treturn searchBandwidthOnWindows(result);\n\t\t}\n\t\telse if (SystemUtils.IS_OS_LINUX)\n\t\t{\n\t\t\t// Get default interface\n\t\t\tvar iface = OsUtils.shellExecute(\"sh\", \"-c\", \"ip route show default | awk '/default/ {print $5}'\").trim();\n\t\t\tif (!iface.isEmpty())\n\t\t\t{\n\t\t\t\tvar result = OsUtils.shellExecute(\"cat\", \"/sys/class/net/\" + iface + \"/speed\");\n\t\t\t\treturn searchBandwidthOnLinux(result);\n\t\t\t}\n\t\t}\n\t\telse if (SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\t// Use en0 as default, or detect default interface\n\t\t\tvar result = OsUtils.shellExecute(\"ifconfig\", \"en0\");\n\t\t\treturn searchBandwidthOnMac(result);\n\t\t}\n\t\treturn 0L;\n\t}\n\n\tstatic long searchBandwidthOnWindows(String input)\n\t{\n\t\tif (!StringUtils.isBlank(input))\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\treturn input.lines()\n\t\t\t\t\t\t.map(Long::parseLong)\n\t\t\t\t\t\t.max(Comparator.naturalOrder())\n\t\t\t\t\t\t.orElse(0L);\n\t\t\t}\n\t\t\tcatch (NumberFormatException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Couldn't parse windows interface bandwidth output: {}\", e.getMessage());\n\t\t\t}\n\t\t}\n\t\treturn 0L;\n\t}\n\n\tstatic long searchBandwidthOnLinux(String input)\n\t{\n\t\tif (!StringUtils.isBlank(input) && LINUX_BANDWIDTH_PATTERN.matcher(input).matches())\n\t\t{\n\t\t\treturn Long.parseLong(input.trim()) * 1_000_000L; // Convert Mbps to bps\n\t\t}\n\t\treturn 0L;\n\t}\n\n\tstatic long searchBandwidthOnMac(String input)\n\t{\n\t\tif (!StringUtils.isBlank(input))\n\t\t{\n\t\t\t// Find \"media:\" line and extract speed\n\t\t\tvar mediaLine = input.lines()\n\t\t\t\t\t.filter(line -> line.contains(\"media:\"))\n\t\t\t\t\t.findFirst();\n\t\t\tif (mediaLine.isPresent())\n\t\t\t{\n\t\t\t\tvar matcher = MACOS_BANDWIDTH_PATTERN.matcher(mediaLine.get());\n\t\t\t\tif (matcher.find())\n\t\t\t\t{\n\t\t\t\t\treturn Long.parseLong(matcher.group(1)) * 1_000_000L; // Convert Mbps to bps\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn 0L;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/bandwidth/item/BandwidthAllowedItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.bandwidth.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class BandwidthAllowedItem extends Item\n{\n\t@RsSerialized(tlvType = TlvType.INT_BANDWIDTH)\n\tprivate int allowedBandwidth;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic BandwidthAllowedItem()\n\t{\n\t}\n\n\tpublic BandwidthAllowedItem(long allowedBandwidth)\n\t{\n\t\tthis.allowedBandwidth = toUnsignedIntSaturated(allowedBandwidth);\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.BANDWIDTH_CONTROL.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\tpublic long getAllowedBandwidth()\n\t{\n\t\treturn Integer.toUnsignedLong(allowedBandwidth);\n\t}\n\n\tprivate static int toUnsignedIntSaturated(long value)\n\t{\n\t\tif (value >= 4_294_967_296L)\n\t\t{\n\t\t\treturn Integer.MIN_VALUE; // Maximum value of an unsigned int\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn (int) (value & 0xFFFFFFFFL);\n\t\t}\n\t}\n\n\t@Override\n\tpublic BandwidthAllowedItem clone()\n\t{\n\t\treturn (BandwidthAllowedItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"BandwidthAllowedItem{\" +\n\t\t\t\t\"allowedBandwidth=\" + allowedBandwidth +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/board/BoardRsService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.board;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.gxs.*;\nimport io.xeres.app.database.repository.GxsBoardGroupRepository;\nimport io.xeres.app.database.repository.GxsBoardMessageRepository;\nimport io.xeres.app.database.repository.GxsCommentMessageRepository;\nimport io.xeres.app.database.repository.GxsVoteMessageRepository;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.notification.board.BoardNotificationService;\nimport io.xeres.app.util.GxsUtils;\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.board.item.BoardGroupItem;\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.app.xrs.service.gxs.GxsAuthentication;\nimport io.xeres.app.xrs.service.gxs.GxsHelperService;\nimport io.xeres.app.xrs.service.gxs.GxsRsService;\nimport io.xeres.app.xrs.service.gxs.GxsTransactionManager;\nimport io.xeres.app.xrs.service.gxs.item.GxsSyncMessageRequestItem;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.gxs.GxsGroupConstants;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.image.ImageUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport javax.imageio.ImageIO;\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nimport static io.xeres.app.util.GxsUtils.IMAGE_MAX_INPUT_SIZE;\nimport static io.xeres.app.util.GxsUtils.MAXIMUM_GXS_MESSAGE_SIZE;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_AUTHOR;\nimport static io.xeres.common.protocol.xrs.RsServiceType.GXS_BOARDS;\n\n@Component\npublic class BoardRsService extends GxsRsService<BoardGroupItem, BoardMessageItem>\n{\n\tprivate static final int IMAGE_MESSAGE_WIDTH = 640;\n\tprivate static final int IMAGE_MESSAGE_HEIGHT = 480;\n\n\tprivate static final Duration SYNCHRONIZATION_INITIAL_DELAY = Duration.ofMinutes(1);\n\tprivate static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1);\n\n\tprivate final GxsBoardGroupRepository gxsBoardGroupRepository;\n\tprivate final GxsBoardMessageRepository gxsBoardMessageRepository;\n\tprivate final GxsHelperService<BoardGroupItem, BoardMessageItem> gxsHelperService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final BoardNotificationService boardNotificationService;\n\tprivate final GxsCommentMessageRepository gxsCommentMessageRepository;\n\tprivate final GxsVoteMessageRepository gxsVoteMessageRepository;\n\n\tpublic BoardRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, GxsBoardGroupRepository gxsBoardGroupRepository, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsBoardMessageRepository gxsBoardMessageRepository, GxsHelperService<BoardGroupItem, BoardMessageItem> gxsHelperService, BoardNotificationService boardNotificationService, GxsCommentMessageRepository gxsCommentMessageRepository, GxsVoteMessageRepository gxsVoteMessageRepository)\n\t{\n\t\tsuper(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService);\n\t\tthis.gxsBoardGroupRepository = gxsBoardGroupRepository;\n\t\tthis.gxsBoardMessageRepository = gxsBoardMessageRepository;\n\t\tthis.gxsHelperService = gxsHelperService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.boardNotificationService = boardNotificationService;\n\t\tthis.gxsCommentMessageRepository = gxsCommentMessageRepository;\n\t\tthis.gxsVoteMessageRepository = gxsVoteMessageRepository;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn GXS_BOARDS;\n\t}\n\n\t@Override\n\tprotected GxsAuthentication getAuthentication()\n\t{\n\t\t// Anybody can post on a board\n\t\treturn new GxsAuthentication.Builder()\n\t\t\t\t.withRequirements(EnumSet.of(ROOT_NEEDS_AUTHOR, CHILD_NEEDS_AUTHOR))\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tsuper.initialize(peerConnection);\n\t\tpeerConnection.scheduleWithFixedDelay(\n\t\t\t\t() -> syncMessages(peerConnection),\n\t\t\t\tSYNCHRONIZATION_INITIAL_DELAY.toSeconds(),\n\t\t\t\tSYNCHRONIZATION_DELAY.toSeconds(),\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\t@Override\n\tprotected void syncMessages(PeerConnection recipient)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\t// Request new messages for all subscribed groups\n\t\t\tfindAllSubscribedGroups().forEach(boardGroupItem -> {\n\t\t\t\tvar request = new GxsSyncMessageRequestItem(boardGroupItem.getGxsId(), gxsHelperService.getLastPeerMessagesUpdate(recipient.getLocation(), boardGroupItem.getGxsId(), getServiceType()), ChronoUnit.YEARS.getDuration());\n\t\t\t\tlog.debug(\"Asking {} for new messages in {} ({}) since {}, last updated: {}\",\n\t\t\t\t\t\trecipient,\n\t\t\t\t\t\tboardGroupItem.getName(),\n\t\t\t\t\t\tboardGroupItem.getGxsId(),\n\t\t\t\t\t\tlog.isDebugEnabled() ? Instant.ofEpochSecond(request.getLimit()) : null,\n\t\t\t\t\t\tlog.isDebugEnabled() ? Instant.ofEpochSecond(request.getLastUpdated()) : null);\n\t\t\t\tpeerConnectionManager.writeItem(recipient, request, this);\n\t\t\t});\n\t\t}\n\t}\n\n\t// XXX: don't forget about the comments and votes!\n\n\t@Override\n\tprotected List<BoardGroupItem> onAvailableGroupListRequest(PeerConnection recipient)\n\t{\n\t\treturn findAllSubscribedGroups();\n\t}\n\n\t@Override\n\tprotected List<BoardGroupItem> onGroupListRequest(Set<GxsId> ids)\n\t{\n\t\treturn findAllGroups(ids);\n\t}\n\n\t@Override\n\tprotected Set<GxsId> onAvailableGroupListResponse(Map<GxsId, Instant> ids)\n\t{\n\t\t// We want new boards as well as updated ones\n\t\tvar existingMap = findAllGroups(ids.keySet()).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished));\n\n\t\tids.entrySet().removeIf(gxsIdInstantEntry -> {\n\t\t\tvar existing = existingMap.get(gxsIdInstantEntry.getKey());\n\t\t\treturn existing != null && !gxsIdInstantEntry.getValue().isAfter(existing);\n\t\t});\n\t\treturn ids.keySet();\n\t}\n\n\t@Override\n\tprotected boolean onGroupReceived(BoardGroupItem item)\n\t{\n\t\tlog.debug(\"Received {}, saving/updating...\", item);\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onGroupsSaved(List<BoardGroupItem> items)\n\t{\n\t\tboardNotificationService.addOrUpdateGroups(items);\n\t}\n\n\t@Override\n\tprotected List<BoardMessageItem> onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since)\n\t{\n\t\treturn findAllMessagesInGroupSince(gxsId, since); // Don't return old messages, they're unimportant\n\t}\n\n\t@Override\n\tprotected List<? extends GxsMessageItem> onMessageListRequest(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds);\n\t}\n\n\t@Transactional(readOnly = true)\n\t@Override\n\tprotected List<MsgId> onMessageListResponse(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\tvar existing = findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds).stream()\n\t\t\t\t.map(GxsMessageItem::getMsgId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tmsgIds.removeAll(existing);\n\n\t\treturn msgIds.stream().toList();\n\t}\n\n\t@Override\n\tprotected boolean onMessageReceived(BoardMessageItem item)\n\t{\n\t\tif (item.hasImage())\n\t\t{\n\t\t\t// Set the dimensions in the database so that images don't cause layout\n\t\t\t// problems when displaying them in long lists without fixed size.\n\t\t\tvar dimension = ImageUtils.getImageDimension(new ByteArrayInputStream(item.getImage()));\n\t\t\tif (dimension != null)\n\t\t\t{\n\t\t\t\titem.setImageWidth((int) dimension.getWidth());\n\t\t\t\titem.setImageHeight((int) dimension.getHeight());\n\t\t\t}\n\t\t}\n\t\tlog.debug(\"Received message {}, saving...\", item);\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onMessagesSaved(List<BoardMessageItem> items)\n\t{\n\t\tboardNotificationService.addOrUpdateMessages(items);\n\t}\n\n\t@Override\n\tprotected boolean onCommentReceived(CommentMessageItem item)\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onCommentsSaved(List<CommentMessageItem> items)\n\t{\n\t\t// XXX: boardNotificationService.addBoardComments(items);\n\t}\n\n\t@Override\n\tprotected boolean onVoteReceived(VoteMessageItem item)\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onVotesSaved(List<VoteMessageItem> items)\n\t{\n\t\t// XXX: boardNotificationService.addBoardVotes(items);\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tsuper.handleItem(sender, item); // This is required for the @Transactional to work\n\t}\n\n\tpublic Optional<BoardGroupItem> findById(long id)\n\t{\n\t\treturn gxsBoardGroupRepository.findById(id);\n\t}\n\n\tpublic List<BoardGroupItem> findAllGroups()\n\t{\n\t\treturn gxsBoardGroupRepository.findAll();\n\t}\n\n\tpublic List<BoardGroupItem> findAllSubscribedGroups()\n\t{\n\t\treturn gxsBoardGroupRepository.findAllBySubscribedIsTrue();\n\t}\n\n\tpublic List<BoardGroupItem> findAllGroups(Set<GxsId> gxsIds)\n\t{\n\t\treturn gxsBoardGroupRepository.findAllByGxsIdIn(gxsIds);\n\t}\n\n\tpublic List<BoardMessageItem> findAllMessagesInGroupSince(GxsId gxsId, Instant since)\n\t{\n\t\treturn gxsBoardMessageRepository.findAllByGxsIdAndPublishedAfterAndHiddenFalse(gxsId, since);\n\t}\n\n\tpublic List<BoardMessageItem> findAllMessages(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn gxsBoardMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(gxsId, msgIds);\n\t}\n\n\tpublic List<BoardMessageItem> findAllMessagesIncludingOlds(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn gxsBoardMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\t}\n\n\tpublic List<GxsMessageItem> findAllMessagesVotesAndCommentsIncludingOlds(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\tvar messages = findAllMessagesIncludingOlds(gxsId, msgIds);\n\t\tvar votes = gxsVoteMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\t\tvar comments = gxsCommentMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\n\t\treturn Stream.of(messages.stream(), votes.stream(), comments.stream())\n\t\t\t\t.flatMap(stream -> stream)\n\t\t\t\t.collect(Collectors.toList());\n\t}\n\n\t@Transactional\n\tpublic Page<BoardMessageItem> findAllMessages(long groupId, Pageable pageable)\n\t{\n\t\tvar boardGroup = gxsBoardGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsBoardMessageRepository.findAllByGxsIdAndHiddenFalse(boardGroup.getGxsId(), pageable);\n\t}\n\n\tpublic Optional<BoardMessageItem> findMessageById(long id)\n\t{\n\t\treturn gxsBoardMessageRepository.findById(id);\n\t}\n\n\t/**\n\t * Finds all messages. Prefer the other variants as this one is slower.\n\t *\n\t * @param msgIds the list of message ids\n\t * @return the messages\n\t */\n\tpublic List<BoardMessageItem> findAllMessages(Set<MsgId> msgIds)\n\t{\n\t\treturn gxsBoardMessageRepository.findAllByMsgIdInAndHiddenFalse(msgIds);\n\t}\n\n\tpublic List<BoardMessageItem> findAllMessages(long groupId, Set<MsgId> msgIds)\n\t{\n\t\tvar boardGroup = gxsBoardGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsBoardMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(boardGroup.getGxsId(), msgIds);\n\t}\n\n\tpublic int getUnreadCount(long groupId)\n\t{\n\t\tvar boardGroupItem = gxsBoardGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsBoardMessageRepository.countUnreadMessages(boardGroupItem.getGxsId());\n\t}\n\n\t@Transactional\n\tpublic long createBoardGroup(GxsId identity, String name, String description, MultipartFile imageFile) throws IOException\n\t{\n\t\tvar group = createGroup(name, false);\n\t\tgroup.setDescription(description);\n\n\t\tif (imageFile != null && !imageFile.isEmpty())\n\t\t{\n\t\t\tgroup.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE));\n\t\t}\n\n\t\tif (identity != null)\n\t\t{\n\t\t\tgroup.setAuthorGxsId(identity);\n\t\t}\n\n\t\tgroup.setCircleType(GxsCircleType.PUBLIC); // XXX: implement \"YOUR_FRIENDS_ONLY\"? but based on trust instead\n\t\tgroup.setSignatureFlags(Set.of(GxsSignatureFlags.NONE_REQUIRED, GxsSignatureFlags.AUTHENTICATION_REQUIRED));\n\t\tgroup.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC));\n\n\t\t//boardGroupItem.setInternalCircle(); XXX: needs that for \"YOUR_FRIENDS_ONLY\". check what RS does for createBoardV2(), how it is called\n\n\t\tgroup.setSubscribed(true);\n\n\t\tgroup = saveBoard(group);\n\n\t\tboardNotificationService.addOrUpdateGroups(List.of(group));\n\n\t\treturn group.getId();\n\t}\n\n\t@Transactional\n\tpublic void updateBoardGroup(long groupId, String name, String description, MultipartFile imageFile, boolean updateImage) throws IOException\n\t{\n\t\tvar boardGroupItem = gxsBoardGroupRepository.findById(groupId).orElseThrow();\n\t\tboardGroupItem.setName(name);\n\t\tboardGroupItem.setDescription(description);\n\t\tif (updateImage)\n\t\t{\n\t\t\tif (imageFile != null)\n\t\t\t{\n\t\t\t\tif (!imageFile.isEmpty())\n\t\t\t\t{\n\t\t\t\t\tboardGroupItem.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE));\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tboardGroupItem.setImage(null); // Remove the image\n\t\t\t}\n\t\t}\n\n\t\tboardGroupItem = saveBoard(boardGroupItem);\n\t\tboardNotificationService.addOrUpdateGroups(List.of(boardGroupItem));\n\t}\n\n\t@Transactional\n\tpublic BoardGroupItem saveBoard(BoardGroupItem boardGroupItem)\n\t{\n\t\tsignGroupIfNeeded(boardGroupItem);\n\t\tvar savedBoard = gxsBoardGroupRepository.save(boardGroupItem);\n\t\tgxsHelperService.setLastServiceGroupsUpdateNow(GXS_BOARDS);\n\t\tpeerConnectionManager.doForAllPeers(this::sendSyncNotification, this);\n\t\treturn savedBoard;\n\t}\n\n\t@Transactional\n\tpublic long createBoardMessage(IdentityGroupItem author, long boardId, String title, String content, String link, MultipartFile imageFile) throws IOException\n\t{\n\t\tint size = title.length();\n\n\t\tvar group = gxsBoardGroupRepository.findById(boardId).orElseThrow();\n\t\tvar builder = new MessageBuilder(group, author, title);\n\n\t\tif (StringUtils.isNotBlank(content))\n\t\t{\n\t\t\tbuilder.getMessageItem().setContent(content);\n\t\t\tsize += content.length();\n\t\t}\n\t\tif (StringUtils.isNotEmpty(link))\n\t\t{\n\t\t\tbuilder.getMessageItem().setLink(link);\n\t\t\tsize += link.length();\n\t\t}\n\n\t\tif (imageFile != null && !imageFile.isEmpty())\n\t\t{\n\t\t\tif (imageFile.getSize() >= IMAGE_MAX_INPUT_SIZE)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Board message image size is bigger than \" + IMAGE_MAX_INPUT_SIZE + \" bytes\");\n\t\t\t}\n\n\t\t\tvar image = ImageUtils.limitMaximumImageSize(ImageIO.read(imageFile.getInputStream()), IMAGE_MESSAGE_WIDTH * IMAGE_MESSAGE_HEIGHT);\n\t\t\tvar imageOut = new ByteArrayOutputStream();\n\t\t\tif (!ImageUtils.writeImageAsJpeg(image, MAXIMUM_GXS_MESSAGE_SIZE - size, imageOut))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Couldn't write the image. Unsupported format?\");\n\t\t\t}\n\n\t\t\tvar data = imageOut.toByteArray();\n\t\t\tbuilder.getMessageItem().setImage(data);\n\t\t\tsize += data.length;\n\t\t}\n\n\t\tif (size >= MAXIMUM_GXS_MESSAGE_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"The message is too large. Reduce the content and/or the image.\");\n\t\t}\n\n\t\tvar boardMessageItem = saveMessage(builder);\n\n\t\tboardNotificationService.addOrUpdateMessages(List.of(boardMessageItem));\n\n\t\tpeerConnectionManager.doForAllPeers(this::sendSyncNotification, this);\n\n\t\treturn boardMessageItem.getId();\n\t}\n\n\tprivate BoardMessageItem saveMessage(MessageBuilder messageBuilder)\n\t{\n\t\tvar boardMessageItem = messageBuilder.build();\n\n\t\tboardMessageItem.setId(gxsBoardMessageRepository.findByGxsIdAndMsgId(boardMessageItem.getGxsId(), boardMessageItem.getMsgId()).orElse(boardMessageItem).getId()); // XXX: not sure we should be able to overwrite a message. in which case is it correct? maybe throw?\n\t\tvar savedMessage = gxsBoardMessageRepository.save(boardMessageItem);\n\t\tmarkOriginalMessageAsHidden(List.of(savedMessage));\n\t\tvar boardGroupItem = gxsBoardGroupRepository.findByGxsId(boardMessageItem.getGxsId()).orElseThrow();\n\t\tboardGroupItem.setLastUpdated(Instant.now());\n\t\tgxsBoardGroupRepository.save(boardGroupItem);\n\t\treturn savedMessage;\n\t}\n\n\t@Transactional\n\tpublic void subscribeToBoardGroup(long id)\n\t{\n\t\tvar boardGroupItem = findById(id).orElseThrow();\n\t\tboardGroupItem.setSubscribed(true);\n\t\tgxsHelperService.setLastServiceGroupsUpdateNow(GXS_BOARDS);\n\t\t// We don't need to send a sync notify here because it's not urgent.\n\t\t// The peers will poll normally to show if there's a new group available.\n\t}\n\n\t@Transactional\n\tpublic void unsubscribeFromBoardGroup(long id)\n\t{\n\t\tvar boardGroupItem = findById(id).orElseThrow();\n\t\tboardGroupItem.setSubscribed(false);\n\t}\n\n\t@Transactional\n\tpublic void setMessageReadState(long messageId, boolean read)\n\t{\n\t\tvar message = gxsBoardMessageRepository.findById(messageId).orElseThrow();\n\t\tmessage.setRead(read);\n\t\tvar group = gxsBoardGroupRepository.findByGxsId(message.getGxsId()).orElseThrow();\n\t\tboardNotificationService.setMessageReadState(group.getId(), message.getId(), read);\n\t}\n\n\t@Transactional\n\tpublic void setAllGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tvar group = gxsBoardGroupRepository.findById(groupId).orElseThrow();\n\t\tgxsBoardMessageRepository.setAllGroupMessagesReadState(group.getGxsId(), read);\n\t\tboardNotificationService.setGroupMessagesReadState(groupId, read);\n\t}\n\n\t@Override\n\tpublic void shutdown()\n\t{\n\t\tboardNotificationService.shutdown();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/board/item/BoardGroupItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.board.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.util.ByteUnitUtils;\nimport jakarta.persistence.Entity;\nimport org.apache.commons.lang3.ArrayUtils;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.deserialize;\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_DESCR;\n\n@Entity(name = \"board_group\")\npublic class BoardGroupItem extends GxsGroupItem\n{\n\tprivate String description;\n\n\tprivate byte[] image;\n\n\tpublic BoardGroupItem()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic BoardGroupItem(GxsId gxsId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n\n\tpublic void setDescription(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn image != null;\n\t}\n\n\tpublic byte[] getImage()\n\t{\n\t\treturn image;\n\t}\n\n\tpublic void setImage(byte[] image)\n\t{\n\t\tif (ArrayUtils.isNotEmpty(image))\n\t\t{\n\t\t\tthis.image = image;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.image = null;\n\t\t}\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, STR_DESCR, description);\n\t\tif (hasImage())\n\t\t{\n\t\t\tsize += serialize(buf, TlvType.IMAGE, image);\n\t\t}\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tdescription = (String) deserialize(buf, STR_DESCR);\n\n\t\tif (buf.isReadable())\n\t\t{\n\t\t\tsetImage((byte[]) deserialize(buf, TlvType.IMAGE));\n\t\t}\n\t}\n\n\t@Override\n\tpublic BoardGroupItem clone()\n\t{\n\t\treturn (BoardGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"BoardGroupItem{\" +\n\t\t\t\tsuper.toString() +\n\t\t\t\t\", image=\" + (image != null ? (\"yes, \" + ByteUnitUtils.fromBytes(image.length)) : \"no\") +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/board/item/BoardMessageItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.board.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.util.ByteUnitUtils;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Transient;\nimport org.apache.commons.lang3.ArrayUtils;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.deserialize;\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_LINK;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\n\n@Entity(name = \"board_message\")\npublic class BoardMessageItem extends GxsMessageItem\n{\n\t@Transient\n\tpublic static final BoardMessageItem EMPTY = new BoardMessageItem();\n\n\tprivate String link;\n\n\tprivate String content;\n\n\tprivate byte[] image;\n\n\tprivate int imageWidth;\n\n\tprivate int imageHeight;\n\n\tprivate boolean read;\n\n\tpublic BoardMessageItem()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic BoardMessageItem(GxsId gxsId, MsgId msgId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetMsgId(msgId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\tpublic String getLink()\n\t{\n\t\treturn link;\n\t}\n\n\tpublic void setLink(String link)\n\t{\n\t\tthis.link = link;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn image != null;\n\t}\n\n\tpublic byte[] getImage()\n\t{\n\t\treturn image;\n\t}\n\n\tpublic void setImage(byte[] image)\n\t{\n\t\tif (ArrayUtils.isNotEmpty(image))\n\t\t{\n\t\t\tthis.image = image;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.image = null;\n\t\t}\n\t}\n\n\tpublic int getImageWidth()\n\t{\n\t\treturn imageWidth;\n\t}\n\n\tpublic void setImageWidth(int imageWidth)\n\t{\n\t\tthis.imageWidth = imageWidth;\n\t}\n\n\tpublic int getImageHeight()\n\t{\n\t\treturn imageHeight;\n\t}\n\n\tpublic void setImageHeight(int imageHeight)\n\t{\n\t\tthis.imageHeight = imageHeight;\n\t}\n\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\tpublic void setRead(boolean read)\n\t{\n\t\tthis.read = read;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, STR_LINK, link);\n\t\tsize += serialize(buf, STR_MSG, content);\n\t\tif (hasImage())\n\t\t{\n\t\t\tsize += serialize(buf, TlvType.IMAGE, image);\n\t\t}\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tlink = (String) deserialize(buf, STR_LINK);\n\t\tcontent = (String) deserialize(buf, STR_MSG);\n\n\t\tif (buf.isReadable())\n\t\t{\n\t\t\tsetImage((byte[]) deserialize(buf, TlvType.IMAGE));\n\t\t}\n\t}\n\n\t@Override\n\tpublic BoardMessageItem clone()\n\t{\n\t\treturn (BoardMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"BoardMessageItem{\" +\n\t\t\t\tsuper.toString() +\n\t\t\t\t\", image=\" + (image != null ? (\"yes, \" + ByteUnitUtils.fromBytes(image.length)) : \"no\") +\n\t\t\t\t\", read=\" + read +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/channel/ChannelRsService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.channel;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.gxs.*;\nimport io.xeres.app.database.repository.GxsChannelGroupRepository;\nimport io.xeres.app.database.repository.GxsChannelMessageRepository;\nimport io.xeres.app.database.repository.GxsCommentMessageRepository;\nimport io.xeres.app.database.repository.GxsVoteMessageRepository;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.notification.channel.ChannelNotificationService;\nimport io.xeres.app.util.GxsUtils;\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.channel.item.ChannelGroupItem;\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.app.xrs.service.gxs.GxsAuthentication;\nimport io.xeres.app.xrs.service.gxs.GxsHelperService;\nimport io.xeres.app.xrs.service.gxs.GxsRsService;\nimport io.xeres.app.xrs.service.gxs.GxsTransactionManager;\nimport io.xeres.app.xrs.service.gxs.item.GxsSyncMessageRequestItem;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.gxs.GxsGroupConstants;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.image.ImageUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport javax.imageio.ImageIO;\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nimport static io.xeres.app.util.GxsUtils.IMAGE_MAX_INPUT_SIZE;\nimport static io.xeres.app.util.GxsUtils.MAXIMUM_GXS_MESSAGE_SIZE;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_PUBLISH;\nimport static io.xeres.common.protocol.xrs.RsServiceType.GXS_CHANNELS;\n\n@Component\npublic class ChannelRsService extends GxsRsService<ChannelGroupItem, ChannelMessageItem>\n{\n\tprivate static final int IMAGE_MESSAGE_WIDTH = 128; // XXX: how much?! it's some aspect ratio thing, see below\n\tprivate static final int IMAGE_MESSAGE_HEIGHT = 128; // XXX: ditto...\n\n\tprivate static final Duration SYNCHRONIZATION_INITIAL_DELAY = Duration.ofSeconds(90);\n\tprivate static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1);\n\n\tprivate final GxsChannelGroupRepository gxsChannelGroupRepository;\n\tprivate final GxsChannelMessageRepository gxsChannelMessageRepository;\n\tprivate final GxsHelperService<ChannelGroupItem, ChannelMessageItem> gxsHelperService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ChannelNotificationService channelNotificationService;\n\tprivate final GxsCommentMessageRepository gxsCommentMessageRepository;\n\tprivate final GxsVoteMessageRepository gxsVoteMessageRepository;\n\n\tpublic ChannelRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsHelperService<ChannelGroupItem, ChannelMessageItem> gxsHelperService, GxsChannelGroupRepository gxsChannelGroupRepository, GxsChannelMessageRepository gxsChannelMessageRepository, GxsHelperService<ChannelGroupItem, ChannelMessageItem> gxsHelperService1, DatabaseSessionManager databaseSessionManager1, ChannelNotificationService channelNotificationService, GxsCommentMessageRepository gxsCommentMessageRepository, GxsVoteMessageRepository gxsVoteMessageRepository)\n\t{\n\t\tsuper(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService);\n\t\tthis.gxsChannelGroupRepository = gxsChannelGroupRepository;\n\t\tthis.gxsChannelMessageRepository = gxsChannelMessageRepository;\n\t\tthis.gxsHelperService = gxsHelperService1;\n\t\tthis.databaseSessionManager = databaseSessionManager1;\n\t\tthis.channelNotificationService = channelNotificationService;\n\t\tthis.gxsCommentMessageRepository = gxsCommentMessageRepository;\n\t\tthis.gxsVoteMessageRepository = gxsVoteMessageRepository;\n\t}\n\n\t// XXX: don't forget about the comments and votes!\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn GXS_CHANNELS;\n\t}\n\n\t@Override\n\tprotected GxsAuthentication getAuthentication()\n\t{\n\t\t// Only the channel owner can write new posts\n\t\treturn new GxsAuthentication.Builder()\n\t\t\t\t.withRequirements(EnumSet.of(ROOT_NEEDS_PUBLISH, CHILD_NEEDS_AUTHOR))\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tsuper.initialize(peerConnection);\n\t\tpeerConnection.scheduleWithFixedDelay(\n\t\t\t\t() -> syncMessages(peerConnection),\n\t\t\t\tSYNCHRONIZATION_INITIAL_DELAY.toSeconds(),\n\t\t\t\tSYNCHRONIZATION_DELAY.toSeconds(),\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\t@Override\n\tprotected void syncMessages(PeerConnection recipient)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\t// Request new messages for all subscribed groups\n\t\t\tfindAllSubscribedGroups().forEach(channelGroupItem -> {\n\t\t\t\tvar request = new GxsSyncMessageRequestItem(channelGroupItem.getGxsId(), gxsHelperService.getLastPeerMessagesUpdate(recipient.getLocation(), channelGroupItem.getGxsId(), getServiceType()), ChronoUnit.YEARS.getDuration());\n\t\t\t\tlog.debug(\"Asking {} for new messages in {} ({}) since {}, last updated: {}\",\n\t\t\t\t\t\trecipient,\n\t\t\t\t\t\tchannelGroupItem.getName(),\n\t\t\t\t\t\trequest.getGxsId(),\n\t\t\t\t\t\tlog.isDebugEnabled() ? Instant.ofEpochSecond(request.getLimit()) : null,\n\t\t\t\t\t\tlog.isDebugEnabled() ? Instant.ofEpochSecond(request.getLastUpdated()) : null);\n\t\t\t\tpeerConnectionManager.writeItem(recipient, request, this);\n\t\t\t});\n\t\t}\n\t}\n\n\t@Override\n\tprotected List<ChannelGroupItem> onAvailableGroupListRequest(PeerConnection recipient)\n\t{\n\t\treturn findAllSubscribedGroups();\n\t}\n\n\t@Override\n\tprotected List<ChannelGroupItem> onGroupListRequest(Set<GxsId> ids)\n\t{\n\t\treturn findAllGroups(ids);\n\t}\n\n\t@Override\n\tprotected Set<GxsId> onAvailableGroupListResponse(Map<GxsId, Instant> ids)\n\t{\n\t\t// We want new channels as well as updated ones\n\t\tvar existingMap = findAllGroups(ids.keySet()).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished));\n\n\t\tids.entrySet().removeIf(gxsIdInstantEntry -> {\n\t\t\tvar existing = existingMap.get(gxsIdInstantEntry.getKey());\n\t\t\treturn existing != null && !gxsIdInstantEntry.getValue().isAfter(existing);\n\t\t});\n\t\treturn ids.keySet();\n\t}\n\n\t@Override\n\tprotected boolean onGroupReceived(ChannelGroupItem item)\n\t{\n\t\tlog.debug(\"Received {}, saving/updating...\", item);\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onGroupsSaved(List<ChannelGroupItem> items)\n\t{\n\t\tchannelNotificationService.addOrUpdateGroups(items);\n\t}\n\n\t@Override\n\tprotected List<ChannelMessageItem> onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since)\n\t{\n\t\treturn findAllMessagesInGroupSince(gxsId, since); // Don't return old messages, they're unimportant\n\t}\n\n\t@Override\n\tprotected List<? extends GxsMessageItem> onMessageListRequest(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds);\n\t}\n\n\t@Override\n\tprotected List<MsgId> onMessageListResponse(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\tvar existing = findAllMessagesVotesAndCommentsIncludingOlds(gxsId, msgIds).stream()\n\t\t\t\t.map(GxsMessageItem::getMsgId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tmsgIds.removeAll(existing);\n\n\t\treturn msgIds.stream().toList();\n\t}\n\n\t@Override\n\tprotected boolean onMessageReceived(ChannelMessageItem item)\n\t{\n\t\tif (item.hasImage())\n\t\t{\n\t\t\t// Set the dimensions in the database so that images don't cause layout\n\t\t\t// problems when displaying them in long lists without fixed size.\n\t\t\tvar dimension = ImageUtils.getImageDimension(new ByteArrayInputStream(item.getImage()));\n\t\t\tif (dimension != null)\n\t\t\t{\n\t\t\t\titem.setImageWidth((int) dimension.getWidth());\n\t\t\t\titem.setImageHeight((int) dimension.getHeight());\n\t\t\t}\n\t\t}\n\t\tlog.debug(\"Received message {}, saving...\", item);\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onMessagesSaved(List<ChannelMessageItem> items)\n\t{\n\t\tchannelNotificationService.addOrUpdateMessages(items);\n\t}\n\n\t@Override\n\tprotected boolean onCommentReceived(CommentMessageItem item)\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onCommentsSaved(List<CommentMessageItem> items)\n\t{\n\t\t// XXX: channelNotificationService.addChannelComments(items);\n\t}\n\n\t@Override\n\tprotected boolean onVoteReceived(VoteMessageItem item)\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onVotesSaved(List<VoteMessageItem> items)\n\t{\n\t\t// XXX: channelNotificationService.addChannelVotes(items);\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tsuper.handleItem(sender, item); // This is required for the @Transactional to work\n\t}\n\n\tpublic Optional<ChannelGroupItem> findById(long id)\n\t{\n\t\treturn gxsChannelGroupRepository.findById(id);\n\t}\n\n\tpublic List<ChannelGroupItem> findAllGroups()\n\t{\n\t\treturn gxsChannelGroupRepository.findAll();\n\t}\n\n\tpublic List<ChannelGroupItem> findAllSubscribedGroups()\n\t{\n\t\treturn gxsChannelGroupRepository.findAllBySubscribedIsTrue();\n\t}\n\n\tpublic List<ChannelGroupItem> findAllGroups(Set<GxsId> gxsIds)\n\t{\n\t\treturn gxsChannelGroupRepository.findAllByGxsIdIn(gxsIds);\n\t}\n\n\tpublic List<ChannelMessageItem> findAllMessagesInGroupSince(GxsId gxsId, Instant since)\n\t{\n\t\treturn gxsChannelMessageRepository.findAllByGxsIdAndPublishedAfterAndHiddenFalse(gxsId, since);\n\t}\n\n\tpublic List<ChannelMessageItem> findAllMessages(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn gxsChannelMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(gxsId, msgIds);\n\t}\n\n\tpublic List<ChannelMessageItem> findAllMessagesIncludingOlds(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn gxsChannelMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\t}\n\n\tpublic List<GxsMessageItem> findAllMessagesVotesAndCommentsIncludingOlds(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\tvar messages = findAllMessagesIncludingOlds(gxsId, msgIds);\n\t\tvar votes = gxsVoteMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\t\tvar comments = gxsCommentMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\n\t\treturn Stream.of(messages.stream(), votes.stream(), comments.stream())\n\t\t\t\t.flatMap(stream -> stream)\n\t\t\t\t.collect(Collectors.toList());\n\t}\n\n\t/**\n\t * Finds all messages. Prefer the other variants as this one is slower.\n\t *\n\t * @param msgIds the list of message ids\n\t * @return the messages\n\t */\n\tpublic List<ChannelMessageItem> findAllMessages(Set<MsgId> msgIds)\n\t{\n\t\treturn gxsChannelMessageRepository.findAllByMsgIdInAndHiddenFalse(msgIds);\n\t}\n\n\tpublic List<ChannelMessageItem> findAllMessages(long groupId, Set<MsgId> msgIds)\n\t{\n\t\tvar channelGroup = gxsChannelGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsChannelMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(channelGroup.getGxsId(), msgIds);\n\t}\n\n\t@Transactional\n\tpublic Page<ChannelMessageItem> findAllMessages(long groupId, Pageable pageable)\n\t{\n\t\tvar channelGroup = gxsChannelGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsChannelMessageRepository.findAllByGxsIdAndHiddenFalse(channelGroup.getGxsId(), pageable);\n\t}\n\n\tpublic Optional<ChannelMessageItem> findMessageById(long id)\n\t{\n\t\treturn gxsChannelMessageRepository.findById(id);\n\t}\n\n\tpublic int getUnreadCount(long groupId)\n\t{\n\t\tvar channelGroupItem = gxsChannelGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsChannelMessageRepository.countUnreadMessages(channelGroupItem.getGxsId());\n\t}\n\n\t@Transactional\n\tpublic long createChannelGroup(GxsId identity, String name, String description, MultipartFile imageFile) throws IOException\n\t{\n\t\tvar group = createGroup(name, true);\n\t\tgroup.setDescription(description);\n\n\t\tif (imageFile != null && !imageFile.isEmpty())\n\t\t{\n\t\t\tgroup.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE));\n\t\t}\n\n\t\tif (identity != null)\n\t\t{\n\t\t\tgroup.setAuthorGxsId(identity);\n\t\t}\n\n\t\tgroup.setCircleType(GxsCircleType.PUBLIC); // XXX: implement \"YOUR_FRIENDS_ONLY\"? but based on trust instead\n\t\tgroup.setSignatureFlags(Set.of(GxsSignatureFlags.NONE_REQUIRED, GxsSignatureFlags.AUTHENTICATION_REQUIRED)); // XXX: correct?\n\t\tgroup.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC));\n\n\t\t//channelGroupItem.setInternalCircle(); XXX: needs that for \"YOUR_FRIENDS_ONLY\". check what RS does for createBoardV2(), how it is called\n\n\t\tgroup.setSubscribed(true);\n\n\t\tgroup = saveChannel(group);\n\n\t\tchannelNotificationService.addOrUpdateGroups(List.of(group));\n\n\t\treturn group.getId();\n\t}\n\n\t@Transactional\n\tpublic void updateChannelGroup(long groupId, String name, String description, MultipartFile imageFile, boolean updateImage) throws IOException\n\t{\n\t\tvar channelGroupItem = gxsChannelGroupRepository.findById(groupId).orElseThrow();\n\t\tchannelGroupItem.setName(name);\n\t\tchannelGroupItem.setDescription(description);\n\t\tif (updateImage)\n\t\t{\n\t\t\tif (imageFile != null)\n\t\t\t{\n\t\t\t\tif (!imageFile.isEmpty())\n\t\t\t\t{\n\t\t\t\t\tchannelGroupItem.setImage(GxsUtils.getScaledGroupImage(imageFile, GxsGroupConstants.IMAGE_SIDE_SIZE));\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tchannelGroupItem.setImage(null); // Remove the image\n\t\t\t}\n\t\t}\n\n\t\tchannelGroupItem = saveChannel(channelGroupItem);\n\t\tchannelNotificationService.addOrUpdateGroups(List.of(channelGroupItem));\n\t}\n\n\tprivate ChannelGroupItem saveChannel(ChannelGroupItem channelGroupItem)\n\t{\n\t\tsignGroupIfNeeded(channelGroupItem);\n\t\tvar savedChannel = gxsChannelGroupRepository.save(channelGroupItem);\n\t\tgxsHelperService.setLastServiceGroupsUpdateNow(GXS_CHANNELS);\n\t\tpeerConnectionManager.doForAllPeers(this::sendSyncNotification, this);\n\t\treturn savedChannel;\n\t}\n\n\t@Transactional\n\tpublic long createChannelMessage(IdentityGroupItem author, long channelId, String title, String content, MultipartFile imageFile, List<FileItem> files, long originalId) throws IOException\n\t{\n\t\tint size = title.length();\n\n\t\tvar group = gxsChannelGroupRepository.findById(channelId).orElseThrow();\n\n\t\tvar builder = new MessageBuilder(group, author, title);\n\n\t\tif (StringUtils.isNotBlank(content))\n\t\t{\n\t\t\tbuilder.getMessageItem().setContent(content);\n\t\t\tsize += content.length();\n\t\t}\n\n\t\t// XXX: for the image, there are 3 aspect ratio: 1:1, 3:4 and 16:9 (and auto, which picks up the closest one of the original image?)\n\t\tif (imageFile != null && !imageFile.isEmpty())\n\t\t{\n\t\t\tif (imageFile.getSize() >= IMAGE_MAX_INPUT_SIZE)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Board message image size is bigger than \" + IMAGE_MAX_INPUT_SIZE + \" bytes\");\n\t\t\t}\n\n\t\t\tvar image = ImageUtils.limitMaximumImageSize(ImageIO.read(imageFile.getInputStream()), IMAGE_MESSAGE_WIDTH * IMAGE_MESSAGE_HEIGHT);\n\t\t\tvar imageOut = new ByteArrayOutputStream();\n\t\t\tif (!ImageUtils.writeImageAsJpeg(image, MAXIMUM_GXS_MESSAGE_SIZE - size, imageOut))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Couldn't write the image. Unsupported format?\");\n\t\t\t}\n\n\t\t\tvar data = imageOut.toByteArray();\n\t\t\tbuilder.getMessageItem().setImage(data);\n\t\t\tsize += data.length;\n\t\t}\n\n\t\tbuilder.getMessageItem().setFiles(files);\n\n\t\tif (originalId != 0L)\n\t\t{\n\t\t\tbuilder.originalMsgId(gxsChannelMessageRepository.findById(originalId).orElseThrow().getMsgId());\n\t\t}\n\n\t\tif (size >= MAXIMUM_GXS_MESSAGE_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"The message is too large. Reduce the content.\");\n\t\t}\n\n\t\tvar channelMessageItem = saveMessage(builder);\n\n\t\tchannelNotificationService.addOrUpdateMessages(List.of(channelMessageItem));\n\n\t\tpeerConnectionManager.doForAllPeers(this::sendSyncNotification, this);\n\n\t\treturn channelMessageItem.getId();\n\t}\n\n\tprivate ChannelMessageItem saveMessage(MessageBuilder messageBuilder)\n\t{\n\t\tvar channelMessageItem = messageBuilder.build();\n\n\t\tchannelMessageItem.setId(gxsChannelMessageRepository.findByGxsIdAndMsgId(channelMessageItem.getGxsId(), channelMessageItem.getMsgId()).orElse(channelMessageItem).getId()); // XXX: not sure we should be able to overwrite a message. in which case is it correct? maybe throw?\n\t\tvar savedMessage = gxsChannelMessageRepository.save(channelMessageItem);\n\t\tmarkOriginalMessageAsHidden(List.of(savedMessage));\n\t\tvar channelGroupItem = gxsChannelGroupRepository.findByGxsId(channelMessageItem.getGxsId()).orElseThrow();\n\t\tchannelGroupItem.setLastUpdated(Instant.now());\n\t\tgxsChannelGroupRepository.save(channelGroupItem);\n\t\treturn savedMessage;\n\t}\n\n\t@Transactional\n\tpublic void subscribeToChannelGroup(long id)\n\t{\n\t\tvar channelGroupItem = findById(id).orElseThrow();\n\t\tchannelGroupItem.setSubscribed(true);\n\t\tgxsHelperService.setLastServiceGroupsUpdateNow(GXS_CHANNELS);\n\t\t// We don't need to send a sync notify here because it's not urgent.\n\t\t// The peers will poll normally to show if there's a new group available.\n\t}\n\n\t@Transactional\n\tpublic void unsubscribeFromChannelGroup(long id)\n\t{\n\t\tvar channelGroupItem = findById(id).orElseThrow();\n\t\tchannelGroupItem.setSubscribed(false);\n\t}\n\n\t@Transactional\n\tpublic void setMessageReadState(long messageId, boolean read)\n\t{\n\t\tvar message = gxsChannelMessageRepository.findById(messageId).orElseThrow();\n\t\tmessage.setRead(read);\n\t\tvar group = gxsChannelGroupRepository.findByGxsId(message.getGxsId()).orElseThrow();\n\t\tchannelNotificationService.setMessageReadState(group.getId(), message.getId(), read);\n\t}\n\n\t@Transactional\n\tpublic void setAllGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tvar group = gxsChannelGroupRepository.findById(groupId).orElseThrow();\n\t\tgxsChannelMessageRepository.setAllGroupMessagesReadState(group.getGxsId(), read);\n\t\tchannelNotificationService.setGroupMessagesReadState(groupId, read);\n\t}\n\n\t@Override\n\tpublic void shutdown()\n\t{\n\t\tchannelNotificationService.shutdown();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/channel/item/ChannelGroupItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.channel.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.util.ByteUnitUtils;\nimport jakarta.persistence.Entity;\nimport org.apache.commons.lang3.ArrayUtils;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.deserialize;\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_DESCR;\n\n@Entity(name = \"channel_group\")\npublic class ChannelGroupItem extends GxsGroupItem\n{\n\tprivate String description;\n\n\tprivate byte[] image;\n\n\tpublic ChannelGroupItem()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic ChannelGroupItem(GxsId gxsId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n\n\tpublic void setDescription(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn image != null;\n\t}\n\n\tpublic byte[] getImage()\n\t{\n\t\treturn image;\n\t}\n\n\tpublic void setImage(byte[] image)\n\t{\n\t\tif (ArrayUtils.isNotEmpty(image))\n\t\t{\n\t\t\tthis.image = image;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.image = null;\n\t\t}\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, STR_DESCR, description);\n\t\tsize += serialize(buf, TlvType.IMAGE, image); // Images are not optional for channels (but can be empty)\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tdescription = (String) deserialize(buf, STR_DESCR);\n\t\tsetImage((byte[]) deserialize(buf, TlvType.IMAGE));\n\t}\n\n\t@Override\n\tpublic ChannelGroupItem clone()\n\t{\n\t\treturn (ChannelGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChannelGroupItem{\" +\n\t\t\t\tsuper.toString() +\n\t\t\t\t\", image=\" + (image != null ? (\"yes, \" + ByteUnitUtils.fromBytes(image.length)) : \"no\") +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/channel/item/ChannelMessageItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.channel.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.app.xrs.common.FileSet;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.util.ByteUnitUtils;\nimport jakarta.persistence.ElementCollection;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Transient;\nimport org.apache.commons.lang3.ArrayUtils;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.deserialize;\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\nimport static io.xeres.app.xrs.serialization.TlvType.FILE_SET;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\n\n@Entity(name = \"channel_message\")\npublic class ChannelMessageItem extends GxsMessageItem\n{\n\t@Transient\n\tpublic static final ChannelMessageItem EMPTY = new ChannelMessageItem();\n\n\tprivate String content;\n\n\t@ElementCollection\n\tprivate List<FileItem> files = new ArrayList<>();\n\n\tprivate String title; // Optional field related to fileset. Not used in practice?\n\n\tprivate String comment; // Optional field related to fileset. Not used in practice?\n\n\tprivate byte[] image;\n\n\tprivate int imageWidth;\n\n\tprivate int imageHeight;\n\n\tprivate boolean read;\n\n\tpublic ChannelMessageItem()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic ChannelMessageItem(GxsId gxsId, MsgId msgId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetMsgId(msgId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\tpublic List<FileItem> getFiles()\n\t{\n\t\treturn files;\n\t}\n\n\tpublic void setFiles(List<FileItem> files)\n\t{\n\t\tthis.files = files;\n\t}\n\n\tpublic String getTitle()\n\t{\n\t\treturn title;\n\t}\n\n\tpublic void setTitle(String title)\n\t{\n\t\tthis.title = title;\n\t}\n\n\tpublic String getComment()\n\t{\n\t\treturn comment;\n\t}\n\n\tpublic void setComment(String comment)\n\t{\n\t\tthis.comment = comment;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn image != null;\n\t}\n\n\tpublic boolean hasFiles()\n\t{\n\t\treturn files != null && !files.isEmpty();\n\t}\n\n\tpublic byte[] getImage()\n\t{\n\t\treturn image;\n\t}\n\n\tpublic void setImage(byte[] image)\n\t{\n\t\tif (ArrayUtils.isNotEmpty(image))\n\t\t{\n\t\t\tthis.image = image;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.image = null;\n\t\t}\n\t}\n\n\tpublic int getImageWidth()\n\t{\n\t\treturn imageWidth;\n\t}\n\n\tpublic void setImageWidth(int imageWidth)\n\t{\n\t\tthis.imageWidth = imageWidth;\n\t}\n\n\tpublic int getImageHeight()\n\t{\n\t\treturn imageHeight;\n\t}\n\n\tpublic void setImageHeight(int imageHeight)\n\t{\n\t\tthis.imageHeight = imageHeight;\n\t}\n\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\tpublic void setRead(boolean read)\n\t{\n\t\tthis.read = read;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, STR_MSG, content);\n\t\tsize += serialize(buf, FILE_SET, new FileSet(files, title, comment));\n\t\tsize += serialize(buf, TlvType.IMAGE, image); // Images are not optional for channels (but can be empty)\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tcontent = (String) deserialize(buf, STR_MSG);\n\n\t\tvar fileSet = (FileSet) deserialize(buf, FILE_SET);\n\t\tfiles = fileSet.fileItems();\n\t\ttitle = fileSet.title();\n\t\tcomment = fileSet.comment();\n\t\tsetImage((byte[]) deserialize(buf, TlvType.IMAGE));\n\t}\n\n\t@Override\n\tpublic ChannelMessageItem clone()\n\t{\n\t\treturn (ChannelMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChannelMessageItem{\" +\n\t\t\t\tsuper.toString() +\n\t\t\t\t\", image=\" + (image != null ? (\"yes, \" + ByteUnitUtils.fromBytes(image.length)) : \"no\") +\n\t\t\t\t\", read=\" + read +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/ChatBacklogService.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.database.model.chat.ChatBacklog;\nimport io.xeres.app.database.model.chat.ChatRoomBacklog;\nimport io.xeres.app.database.model.chat.DistantChatBacklog;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.repository.*;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\n\n@Service\npublic class ChatBacklogService\n{\n\tprivate static final Duration MAXIMUM_DURATION = Duration.ofDays(31);\n\n\tprivate final ChatBacklogRepository chatBacklogRepository;\n\tprivate final ChatRoomBacklogRepository chatRoomBacklogRepository;\n\tprivate final DistantChatBacklogRepository distantChatBacklogRepository;\n\tprivate final LocationRepository locationRepository;\n\tprivate final ChatRoomRepository chatRoomRepository;\n\tprivate final GxsIdentityRepository gxsIdentityRepository;\n\n\tChatBacklogService(ChatBacklogRepository chatBacklogRepository, ChatRoomBacklogRepository chatRoomBacklogRepository, DistantChatBacklogRepository distantChatBacklogRepository, LocationRepository locationRepository, ChatRoomRepository chatRoomRepository, GxsIdentityRepository gxsIdentityRepository)\n\t{\n\t\tthis.chatBacklogRepository = chatBacklogRepository;\n\t\tthis.chatRoomBacklogRepository = chatRoomBacklogRepository;\n\t\tthis.distantChatBacklogRepository = distantChatBacklogRepository;\n\t\tthis.locationRepository = locationRepository;\n\t\tthis.chatRoomRepository = chatRoomRepository;\n\t\tthis.gxsIdentityRepository = gxsIdentityRepository;\n\t}\n\n\t@Transactional\n\tpublic void storeIncomingChatRoomMessage(long chatRoomId, GxsId from, String nickname, String message)\n\t{\n\t\tvar chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow();\n\t\tchatRoomBacklogRepository.save(new ChatRoomBacklog(chatRoom, from, nickname, message));\n\t}\n\n\t@Transactional\n\tpublic void storeOutgoingChatRoomMessage(long chatRoomId, String nickname, String message)\n\t{\n\t\tvar chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow();\n\t\tchatRoomBacklogRepository.save(new ChatRoomBacklog(chatRoom, nickname, message));\n\t}\n\n\t@Transactional(readOnly = true)\n\tpublic List<ChatRoomBacklog> getChatRoomMessages(long chatRoomId, Instant from, int maxLines)\n\t{\n\t\tvar chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow();\n\t\treturn chatRoomBacklogRepository.findAllByRoomAndCreatedAfterOrderByCreatedDesc(chatRoom, from, Limit.of(maxLines)).reversed();\n\t}\n\n\t@Transactional\n\tpublic void deleteChatRoomMessages(long chatRoomId)\n\t{\n\t\tvar chatRoom = chatRoomRepository.findByRoomId(chatRoomId).orElseThrow();\n\t\tchatRoomBacklogRepository.deleteAllByRoom(chatRoom);\n\t}\n\n\t@Transactional\n\tpublic void storeIncomingMessage(LocationIdentifier from, String message)\n\t{\n\t\tvar location = locationRepository.findByLocationIdentifier(from).orElseThrow();\n\t\tchatBacklogRepository.save(new ChatBacklog(location, false, message));\n\t}\n\n\t@Transactional\n\tpublic void storeOutgoingMessage(LocationIdentifier to, String message)\n\t{\n\t\tvar location = locationRepository.findByLocationIdentifier(to).orElseThrow();\n\t\tchatBacklogRepository.save(new ChatBacklog(location, true, message));\n\t}\n\n\tpublic List<ChatBacklog> getMessages(Location with, Instant from, int maxLines)\n\t{\n\t\treturn chatBacklogRepository.findAllByLocationAndCreatedAfterOrderByCreatedDesc(with, from, Limit.of(maxLines)).reversed();\n\t}\n\n\t@Transactional\n\tpublic void deleteMessages(Location of)\n\t{\n\t\tchatBacklogRepository.deleteAllByLocation(of);\n\t}\n\n\t@Transactional\n\tpublic void storeIncomingDistantMessage(GxsId from, String message)\n\t{\n\t\tvar gxsId = gxsIdentityRepository.findByGxsId(from).orElseThrow();\n\t\tdistantChatBacklogRepository.save(new DistantChatBacklog(gxsId, false, message));\n\t}\n\n\t@Transactional\n\tpublic void storeOutgoingDistantMessage(GxsId to, String message)\n\t{\n\t\tvar gxsId = gxsIdentityRepository.findByGxsId(to).orElseThrow();\n\t\tdistantChatBacklogRepository.save(new DistantChatBacklog(gxsId, true, message));\n\t}\n\n\tpublic List<DistantChatBacklog> getDistantMessages(IdentityGroupItem with, Instant from, int maxLines)\n\t{\n\t\treturn distantChatBacklogRepository.findAllByIdentityGroupItemAndCreatedAfterOrderByCreatedDesc(with, from, Limit.of(maxLines)).reversed();\n\t}\n\n\t@Transactional\n\tpublic void deleteDistantMessages(IdentityGroupItem of)\n\t{\n\t\tdistantChatBacklogRepository.deleteAllByIdentityGroupItem(of);\n\t}\n\n\t@Transactional\n\tpublic void cleanup()\n\t{\n\t\tchatBacklogRepository.deleteAllByCreatedBefore(Instant.now().minus(MAXIMUM_DURATION));\n\t\tchatRoomBacklogRepository.deleteAllByCreatedBefore(Instant.now().minus(MAXIMUM_DURATION));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/ChatFlags.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.common.annotation.RsDeprecated;\n\npublic enum ChatFlags\n{\n\t/**\n\t * Set for all direct, distant and chat room messages.\n\t */\n\tPRIVATE,\n\n\t/**\n\t * Set when requesting an avatar. The message must be empty\n\t * and set to private too. Xeres uses it to get the remote icon\n\t * when opening a private/distant chat window.\n\t */\n\tREQUEST_AVATAR,\n\n\t/**\n\t * No longer used.\n\t */\n\t@RsDeprecated\n\tCONTAINS_AVATAR,\n\n\t/**\n\t * Set if we changed our avatar (not used by Xeres).\n\t */\n\tAVATAR_AVAILABLE,\n\n\t/**\n\t * Used to send status strings in a ChatStatusItem (currently not used by Xeres).\n\t */\n\tCUSTOM_STATE,\n\n\t/**\n\t * Set for broadcast messages.\n\t */\n\tPUBLIC,\n\n\t/**\n\t * Used to request a custom string in a ChatStatusItem (currently not used by Xeres).\n\t */\n\tREQUEST_CUSTOM_STATE,\n\n\t/**\n\t * Used to tell we have or changed a status string in a ChatStatusItem\n\t * (currently not used by Xeres).\n\t */\n\tCUSTOM_STATE_AVAILABLE,\n\n\t/**\n\t * Used to tell that this is a large message that is split and needs\n\t * to be reassembled.\n\t */\n\tPARTIAL_MESSAGE,\n\n\t/**\n\t * Always set for ChatRoomMessageItem.\n\t */\n\tLOBBY,\n\n\t/**\n\t * No longer used. Uses Gxs Tunnels instead.\n\t */\n\t@RsDeprecated\n\tCLOSING_DISTANT_CONNECTION,\n\n\t/**\n\t * No longer used. Uses turtle instead.\n\t */\n\t@RsDeprecated\n\tACK_DISTANT_CONNECTION,\n\n\t/**\n\t * No longer used.\n\t */\n\t@RsDeprecated\n\tKEEP_ALIVE,\n\n\t/**\n\t * Set for distant chats to refuse a connection. Currently not used by Xeres.\n\t */\n\tCONNECTION_REFUSED\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/ChatRoom.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.chat.item.VisibleChatRoomInfo;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.chat.ChatRoomInfo;\nimport io.xeres.common.message.chat.RoomType;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.EnumSet;\nimport java.util.HashSet;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ChatRoom\n{\n\tprivate static final long USER_INACTIVITY_TIMEOUT = Duration.ofMinutes(3).toSeconds();\n\n\tprivate final long id;\n\tprivate final String name;\n\tprivate final String topic;\n\tprivate final Set<Location> participatingLocations = ConcurrentHashMap.newKeySet();\n\tprivate GxsId ownGxsId;\n\tprivate final Map<GxsId, Long> users = new ConcurrentHashMap<>();\n\tprivate final int userCount;\n\tprivate Instant lastActivity;\n\tprivate Instant lastSeen = Instant.now();\n\tprivate final RoomType type;\n\tprivate final boolean signed;\n\n\tprivate final MessageCache messageCache = new MessageCache();\n\tprivate LocationIdentifier virtualPeerId; // XXX: check if we need that...\n\tprivate int connectionChallengeCount;\n\tprivate Instant lastConnectionChallenge = Instant.EPOCH;\n\tprivate boolean joinedRoomPacketSent;\n\tprivate Instant lastKeepAlivePacket = Instant.EPOCH;\n\tprivate final Set<LocationIdentifier> previouslyKnownLocations = ConcurrentHashMap.newKeySet();\n\n\tpublic ChatRoom(long id, String name, String topic, RoomType type, int userCount, boolean isSigned)\n\t{\n\t\tthis.id = id;\n\t\tthis.name = name;\n\t\tthis.topic = topic;\n\t\tthis.type = type;\n\t\tthis.userCount = userCount; // XXX: use that if available, other gxsId.size() which is more precise\n\t\tsigned = isSigned;\n\t}\n\n\t/**\n\t * Get as a RoomInfo structure, used for displaying in the UI\n\t *\n\t * @return a RoomInfo\n\t */\n\tpublic ChatRoomInfo getAsRoomInfo()\n\t{\n\t\treturn new ChatRoomInfo(\n\t\t\t\tid,\n\t\t\t\tname,\n\t\t\t\ttype,\n\t\t\t\ttopic,\n\t\t\t\tgetUserCount(),\n\t\t\t\tsigned\n\t\t);\n\t}\n\n\t/**\n\t * Get as a VisibleChatRoomInfo, used for serialization as RS protocol\n\t *\n\t * @return a VisibleChatRoomInfo\n\t */\n\tpublic VisibleChatRoomInfo getAsVisibleChatRoomInfo()\n\t{\n\t\treturn new VisibleChatRoomInfo(\n\t\t\t\tid,\n\t\t\t\tname,\n\t\t\t\ttopic,\n\t\t\t\tgetUserCount(),\n\t\t\t\tgetRoomFlags()\n\t\t);\n\t}\n\n\tprivate int getUserCount()\n\t{\n\t\tvar size = users.size();\n\t\treturn size > 0 ? size : userCount;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic String getTopic()\n\t{\n\t\treturn topic;\n\t}\n\n\tpublic boolean hasParticipatingLocations()\n\t{\n\t\treturn !participatingLocations.isEmpty();\n\t}\n\n\tpublic Set<Location> getParticipatingLocations()\n\t{\n\t\treturn participatingLocations;\n\t}\n\n\tpublic boolean addParticipatingLocation(Location location)\n\t{\n\t\treturn participatingLocations.add(location);\n\t}\n\n\tpublic void removeParticipatingLocation(Location location)\n\t{\n\t\tparticipatingLocations.remove(location);\n\t}\n\n\tpublic void recordPreviouslyKnownLocation(Location location)\n\t{\n\t\tpreviouslyKnownLocations.add(location.getLocationIdentifier());\n\t}\n\n\tpublic boolean isPreviouslyKnownLocation(Location location)\n\t{\n\t\treturn previouslyKnownLocations.contains(location.getLocationIdentifier());\n\t}\n\n\tpublic void setOwnGxsId(GxsId gxsId)\n\t{\n\t\townGxsId = gxsId;\n\t}\n\n\tpublic void addUser(GxsId user)\n\t{\n\t\tusers.put(user, Instant.now().getEpochSecond());\n\t}\n\n\tpublic void userActivity(GxsId user)\n\t{\n\t\tusers.replace(user, Instant.now().getEpochSecond());\n\t}\n\n\tpublic void removeUser(GxsId user)\n\t{\n\t\tusers.remove(user);\n\t}\n\n\tpublic Set<GxsId> getExpiredUsers()\n\t{\n\t\tvar now = Instant.now().getEpochSecond();\n\n\t\tSet<GxsId> expiredUsers = new HashSet<>();\n\t\tusers.forEach((user, timestamp) -> {\n\t\t\tif (timestamp + USER_INACTIVITY_TIMEOUT < now && !user.equals(ownGxsId))\n\t\t\t{\n\t\t\t\texpiredUsers.add(user);\n\t\t\t}\n\t\t});\n\t\treturn expiredUsers;\n\t}\n\n\tpublic void clearUsers()\n\t{\n\t\tusers.clear();\n\t}\n\n\tpublic Instant getLastActivity()\n\t{\n\t\treturn lastActivity;\n\t}\n\n\tpublic void updateActivity()\n\t{\n\t\tlastActivity = Instant.now();\n\t}\n\n\tpublic Instant getLastSeen()\n\t{\n\t\treturn lastSeen;\n\t}\n\n\tpublic void updateLastSeen()\n\t{\n\t\tlastSeen = Instant.now();\n\t}\n\n\tpublic LocationIdentifier getVirtualPeerId()\n\t{\n\t\treturn virtualPeerId;\n\t}\n\n\tpublic int getConnectionChallengeCount()\n\t{\n\t\treturn connectionChallengeCount;\n\t}\n\n\tpublic int getConnectionChallengeCountAndIncrease()\n\t{\n\t\treturn connectionChallengeCount++;\n\t}\n\n\tpublic void resetConnectionChallengeCount()\n\t{\n\t\tconnectionChallengeCount = 0;\n\t\tlastConnectionChallenge = Instant.now();\n\t}\n\n\tpublic Instant getLastConnectionChallenge()\n\t{\n\t\treturn lastConnectionChallenge;\n\t}\n\n\tpublic boolean isJoinedRoomPacketSent()\n\t{\n\t\treturn joinedRoomPacketSent;\n\t}\n\n\tpublic void setJoinedRoomPacketSent(boolean joinedRoomPacketSent)\n\t{\n\t\tthis.joinedRoomPacketSent = joinedRoomPacketSent;\n\t}\n\n\tpublic void setLastKeepAlivePacket(Instant lastKeepAlivePacket)\n\t{\n\t\tthis.lastKeepAlivePacket = lastKeepAlivePacket;\n\t}\n\n\tpublic Instant getLastKeepAlivePacket()\n\t{\n\t\treturn lastKeepAlivePacket;\n\t}\n\n\tpublic boolean isPublic()\n\t{\n\t\treturn type == RoomType.PUBLIC;\n\t}\n\n\tpublic boolean isPrivate()\n\t{\n\t\treturn type == RoomType.PRIVATE;\n\t}\n\n\tpublic boolean isSigned()\n\t{\n\t\treturn signed;\n\t}\n\n\tpublic long getNewMessageId()\n\t{\n\t\treturn messageCache.getNewMessageId();\n\t}\n\n\tpublic void incrementConnectionChallengeCount()\n\t{\n\t\tconnectionChallengeCount++;\n\t}\n\n\tMessageCache getMessageCache()\n\t{\n\t\treturn messageCache;\n\t}\n\n\tpublic Set<RoomFlags> getRoomFlags()\n\t{\n\t\tvar roomFlags = EnumSet.noneOf(RoomFlags.class);\n\t\tif (type == RoomType.PUBLIC)\n\t\t{\n\t\t\troomFlags.add(RoomFlags.PUBLIC);\n\t\t}\n\t\tif (signed)\n\t\t{\n\t\t\troomFlags.add(RoomFlags.PGP_SIGNED);\n\t\t}\n\t\treturn roomFlags;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoom{\" +\n\t\t\t\t\"id=\" + Id.toStringLowerCase(id) +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/ChatRoomService.java",
    "content": "package io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.database.repository.ChatRoomRepository;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\n\n/**\n * Helper service to manage chat room subscriptions and so on.\n */\n@Service\nclass ChatRoomService\n{\n\tprivate final ChatRoomRepository chatRoomRepository;\n\n\tpublic ChatRoomService(ChatRoomRepository chatRoomRepository)\n\t{\n\t\tthis.chatRoomRepository = chatRoomRepository;\n\t}\n\n\t@Transactional\n\tpublic io.xeres.app.database.model.chat.ChatRoom createChatRoom(io.xeres.app.xrs.service.chat.ChatRoom chatRoom, IdentityGroupItem identityGroupItem)\n\t{\n\t\treturn chatRoomRepository.save(io.xeres.app.database.model.chat.ChatRoom.createChatRoom(chatRoom, identityGroupItem));\n\t}\n\n\t@Transactional\n\tpublic io.xeres.app.database.model.chat.ChatRoom subscribeToChatRoomAndJoin(io.xeres.app.xrs.service.chat.ChatRoom chatRoom, IdentityGroupItem identityGroupItem)\n\t{\n\t\tvar entity = chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom.getId(), identityGroupItem).orElseGet(() -> createChatRoom(chatRoom, identityGroupItem));\n\t\tentity.setSubscribed(true);\n\t\tentity.setJoined(true);\n\t\treturn entity;\n\t}\n\n\t@Transactional\n\tpublic io.xeres.app.database.model.chat.ChatRoom unsubscribeFromChatRoomAndLeave(long chatRoomId, IdentityGroupItem identityGroupItem)\n\t{\n\t\tvar foundRoom = chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoomId, identityGroupItem);\n\n\t\tfoundRoom.ifPresent(subscribedRoom -> {\n\t\t\tsubscribedRoom.setSubscribed(false);\n\t\t\tsubscribedRoom.setJoined(false);\n\t\t\tsubscribedRoom.clearLocations();\n\t\t});\n\t\treturn foundRoom.orElse(null);\n\t}\n\n\tpublic void deleteChatRoom(long chatRoomId, IdentityGroupItem identityGroupItem)\n\t{\n\t\tchatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoomId, identityGroupItem).ifPresent(chatRoomRepository::delete);\n\t}\n\n\tpublic List<io.xeres.app.database.model.chat.ChatRoom> getAllChatRoomsPendingToSubscribe()\n\t{\n\t\treturn chatRoomRepository.findAllBySubscribedTrueAndJoinedFalse(); // Remember joined is set to false on startup\n\t}\n\n\tpublic void markAllChatRoomsAsLeft()\n\t{\n\t\tchatRoomRepository.putAllJoinedToFalse();\n\t}\n\n\t@Transactional\n\tpublic void syncParticipatingLocations(io.xeres.app.xrs.service.chat.ChatRoom chatRoom)\n\t{\n\t\tvar room = chatRoomRepository.findByRoomId(chatRoom.getId()).orElseThrow();\n\t\troom.clearLocations();\n\t\tchatRoom.getParticipatingLocations().forEach(room::addLocation);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/ChatRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.application.events.PeerConnectedEvent;\nimport io.xeres.app.application.events.PeerDisconnectedEvent;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.*;\nimport io.xeres.app.service.script.ScriptService;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemUtils;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.chat.item.*;\nimport io.xeres.app.xrs.service.gxstunnel.GxsTunnelRsClient;\nimport io.xeres.app.xrs.service.gxstunnel.GxsTunnelRsService;\nimport io.xeres.app.xrs.service.gxstunnel.GxsTunnelStatus;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.*;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.common.util.SecureRandomUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.common.location.Availability.AVAILABLE;\nimport static io.xeres.common.location.Availability.OFFLINE;\nimport static io.xeres.common.message.MessagePath.*;\nimport static io.xeres.common.message.MessageType.*;\nimport static io.xeres.common.protocol.xrs.RsServiceType.CHAT;\nimport static io.xeres.common.protocol.xrs.RsServiceType.GXS_TUNNELS;\nimport static io.xeres.common.tray.TrayNotificationType.BROADCAST;\n\n@Component\npublic class ChatRsService extends RsService implements GxsTunnelRsClient\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ChatRsService.class);\n\n\t/**\n\t * Time between housekeeping runs to clean up the message cache and so on.\n\t */\n\tprivate static final Duration HOUSEKEEPING_DELAY = Duration.ofSeconds(10);\n\n\t/**\n\t * Maximum time to keep message records.\n\t */\n\tprivate static final Duration KEEP_MESSAGE_RECORD_MAX = Duration.ofMinutes(20);\n\n\t/**\n\t * Maximum of chat rooms accepted by a peer.\n\t * XXX: should be incremented one day\n\t */\n\tprivate static final int CHATROOM_LIST_MAX = 50;\n\n\t/**\n\t * When to refresh nearby chat rooms by asking peers.\n\t */\n\tprivate static final Duration CHATROOM_NEARBY_REFRESH_INITIAL_MIN = Duration.ofSeconds(0);\n\tprivate static final Duration CHATROOM_NEARBY_REFRESH_INITIAL_MAX = Duration.ofSeconds(5);\n\tprivate static final Duration CHATROOM_NEARBY_REFRESH = Duration.ofMinutes(2);\n\n\t/**\n\t * When to remove nearby chat rooms when no peers have them anymore.\n\t */\n\tprivate static final Duration CHATROOM_NEARBY_TIMEOUT = Duration.ofMinutes(3);\n\n\t/**\n\t * Time after which a keep alive packet is sent.\n\t */\n\tprivate static final Duration KEEPALIVE_DELAY = Duration.ofMinutes(2);\n\n\t/**\n\t * Minimum time between connection challenges.\n\t */\n\tprivate static final Duration CONNECTION_CHALLENGE_MIN_DELAY = Duration.ofSeconds(15);\n\n\t/**\n\t * Minimum number of connection challenge counts before one\n\t * can be sent.\n\t */\n\tprivate static final int CONNECTION_CHALLENGE_COUNT_MIN = 20;\n\n\t/**\n\t * Maximum time difference allowed for messages in the past (this doesn't\n\t * account for KEEP_MESSAGE_RECORD_MAX for the total).\n\t */\n\tprivate static final Duration TIME_DRIFT_PAST_MAX = Duration.ofSeconds(100);\n\n\t/**\n\t * Maximum time difference allowed for messages in the future.\n\t */\n\tprivate static final Duration TIME_DRIFT_FUTURE_MAX = Duration.ofMinutes(10);\n\n\t/**\n\t * Content sent with a typing notification. Note that Retroshare displays\n\t * the text directly.\n\t */\n\tprivate static final String MESSAGE_TYPING_CONTENT = \"is typing...\";\n\n\tprivate static final int KEY_PARTIAL_MESSAGE_LIST = 1;\n\n\t/**\n\t * Retroshare puts some limit here.\n\t */\n\tprivate static final int AVATAR_SIZE_MAX = 32767;\n\n\tprivate static final int DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID = 0xa0001;\n\n\tprivate final Map<Long, ChatRoom> chatRooms = new ConcurrentHashMap<>();\n\tprivate final Map<Long, ChatRoom> availableChatRooms = new ConcurrentHashMap<>();\n\tprivate final Map<Long, ChatRoom> invitedChatRooms = new ConcurrentHashMap<>();\n\n\tprivate final Map<GxsId, DistantLocation> distantChatContacts = new ConcurrentHashMap<>();\n\n\t@Override\n\tpublic RsServiceType getMasterServiceType()\n\t{\n\t\treturn GXS_TUNNELS;\n\t}\n\n\tprivate enum Invitation\n\t{\n\t\tPLAIN,\n\t\tFROM_CHALLENGE\n\t}\n\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\tprivate final LocationService locationService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final MessageService messageService;\n\tprivate final IdentityService identityService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final IdentityManager identityManager;\n\tprivate final UiBridgeService uiBridgeService;\n\tprivate final ChatRoomService chatRoomService;\n\tprivate final ChatBacklogService chatBacklogService;\n\tprivate final UnHtmlService unHtmlService;\n\tprivate final ScriptService scriptService;\n\n\tprivate ScheduledExecutorService executorService;\n\tprivate GxsTunnelRsService gxsTunnelRsService;\n\n\tChatRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, LocationService locationService, MessageService messageService, IdentityService identityService, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, UiBridgeService uiBridgeService, ChatRoomService chatRoomService, ChatBacklogService chatBacklogService, UnHtmlService unHtmlService, ScriptService scriptService)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.locationService = locationService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.messageService = messageService;\n\t\tthis.identityService = identityService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.identityManager = identityManager;\n\t\tthis.uiBridgeService = uiBridgeService;\n\t\tthis.chatRoomService = chatRoomService;\n\t\tthis.chatBacklogService = chatBacklogService;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t\tthis.unHtmlService = unHtmlService;\n\t\tthis.scriptService = scriptService;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn CHAT;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.HIGH;\n\t}\n\n\t@Override\n\tpublic int onGxsTunnelInitialization(GxsTunnelRsService gxsTunnelRsService)\n\t{\n\t\tthis.gxsTunnelRsService = gxsTunnelRsService;\n\t\treturn DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID;\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof ChatRoomListRequestItem)\n\t\t{\n\t\t\thandleChatRoomListRequestItem(sender);\n\t\t}\n\t\telse if (item instanceof ChatRoomListItem chatRoomListItem)\n\t\t{\n\t\t\thandleChatRoomListItem(sender, chatRoomListItem);\n\t\t}\n\t\telse if (item instanceof ChatMessageItem chatMessageItem)\n\t\t{\n\t\t\thandleChatMessageItem(sender, chatMessageItem);\n\t\t}\n\t\telse if (item instanceof ChatRoomMessageItem chatRoomMessageItem)\n\t\t{\n\t\t\thandleChatRoomMessageItem(sender, chatRoomMessageItem);\n\t\t}\n\t\telse if (item instanceof ChatStatusItem chatStatusItem)\n\t\t{\n\t\t\thandleChatStatusItem(sender, chatStatusItem);\n\t\t}\n\t\telse if (item instanceof ChatRoomInviteItem chatRoomInviteItem)\n\t\t{\n\t\t\thandleChatRoomInviteItem(sender, chatRoomInviteItem);\n\t\t}\n\t\telse if (item instanceof ChatRoomEventItem chatRoomEventItem)\n\t\t{\n\t\t\thandleChatRoomEventItem(sender, chatRoomEventItem);\n\t\t}\n\t\telse if (item instanceof ChatRoomConnectChallengeItem chatRoomConnectChallengeItem)\n\t\t{\n\t\t\thandleChatRoomConnectChallengeItem(sender, chatRoomConnectChallengeItem);\n\t\t}\n\t\telse if (item instanceof ChatRoomUnsubscribeItem chatRoomUnsubscribeItem)\n\t\t{\n\t\t\thandleChatRoomUnsubscribeItem(sender, chatRoomUnsubscribeItem);\n\t\t}\n\t\telse if (item instanceof ChatAvatarItem chatAvatarItem)\n\t\t{\n\t\t\thandleChatAvatarItem(sender, chatAvatarItem);\n\t\t}\n\t\telse if (item instanceof ChatRoomInviteOldItem chatRoomInviteOldItem)\n\t\t{\n\t\t\thandleChatRoomInviteOldItem(sender, chatRoomInviteOldItem);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onGxsTunnelDataReceived(Location tunnelId, byte[] data)\n\t{\n\t\tvar destination = gxsTunnelRsService.getGxsFromTunnel(tunnelId);\n\t\tif (destination == null)\n\t\t{\n\t\t\tlog.error(\"Cannot get tunnel info from {}\", tunnelId);\n\t\t\treturn;\n\t\t}\n\n\t\tvar distantLocation = distantChatContacts.computeIfAbsent(destination, _ -> new DistantLocation(tunnelId, destination));\n\n\t\tvar item = ItemUtils.deserializeItem(data, rsServiceRegistry);\n\t\tswitch (item)\n\t\t{\n\t\t\tcase ChatMessageItem chatMessageItem -> handleChatMessageItem(distantLocation, chatMessageItem);\n\t\t\tcase ChatAvatarItem chatAvatarItem -> handleChatAvatarItem(distantLocation, chatAvatarItem);\n\t\t\tcase ChatStatusItem chatStatusItem -> handleChatStatusItem(distantLocation, chatStatusItem);\n\t\t\tdefault -> log.error(\"Unknown item {}\", item);\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean onGxsTunnelDataAuthorization(GxsId sender, Location tunnelId, boolean clientSide)\n\t{\n\t\t//noinspection IfStatementWithIdenticalBranches\n\t\tif (clientSide)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\n\t\t// XXX: add code for refusing distant chats\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic void onGxsTunnelStatusChanged(Location tunnelId, GxsId destination, GxsTunnelStatus status)\n\t{\n\t\tswitch (status)\n\t\t{\n\t\t\tcase UNKNOWN -> log.warn(\"Don't know how to handle {}\", status);\n\t\t\tcase CAN_TALK -> messageService.sendToConsumers(chatDistantDestination(), CHAT_AVAILABILITY, destination, AVAILABLE);\n\t\t\tcase TUNNEL_DOWN, REMOTELY_CLOSED -> messageService.sendToConsumers(chatDistantDestination(), CHAT_AVAILABILITY, destination, OFFLINE);\n\t\t}\n\t}\n\n\t@Override\n\t@SuppressWarnings(\"java:S1905\")\n\tpublic void initialize()\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tchatRoomService.markAllChatRoomsAsLeft();\n\t\t\tsubscribeToAllSavedRooms();\n\t\t}\n\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::manageChatRooms,\n\t\t\t\tgetInitPriority().getMaxTime() + HOUSEKEEPING_DELAY.toSeconds() / 2,\n\t\t\t\tHOUSEKEEPING_DELAY.toSeconds());\n\t}\n\n\t@Override\n\tpublic void shutdown()\n\t{\n\t\tchatRooms.forEach((_, chatRoom) -> {\n\t\t\tchatRoomService.syncParticipatingLocations(chatRoom);\n\t\t\tsendChatRoomEvent(chatRoom, ChatRoomEvent.PEER_LEFT);\n\t\t});\n\t\tchatBacklogService.cleanup();\n\t}\n\n\t@Override\n\tpublic void cleanup()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.scheduleAtFixedRate(\n\t\t\t\t() -> askForNearbyChatRooms(peerConnection),\n\t\t\t\tThreadLocalRandom.current().nextLong(CHATROOM_NEARBY_REFRESH_INITIAL_MIN.toSeconds(), CHATROOM_NEARBY_REFRESH_INITIAL_MAX.toSeconds() + 1),\n\t\t\t\tCHATROOM_NEARBY_REFRESH.toSeconds(),\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\tprivate void manageChatRooms()\n\t{\n\t\tchatRooms.forEach((_, chatRoom) -> {\n\t\t\tlog.debug(\"Cleanup of room {}\", chatRoom);\n\n\t\t\t// Remove old messages\n\t\t\tchatRoom.getMessageCache().purge();\n\n\t\t\t// Remove inactive gxsIds\n\t\t\tchatRoom.getExpiredUsers().forEach(user -> {\n\t\t\t\tchatRoom.removeUser(user);\n\t\t\t\tsendChatRoomTimeoutToConsumers(chatRoom.getId(), user, !chatRoom.hasParticipatingLocations());\n\t\t\t});\n\n\t\t\tsendKeepAliveIfNeeded(chatRoom);\n\n\t\t\tsendConnectionChallengeIfNeeded(chatRoom);\n\n\t\t\tsendJoinEventIfNeeded(chatRoom);\n\t\t});\n\n\t\tremoveUnseenRooms();\n\t}\n\n\t/**\n\t * Removes rooms that haven't been seen for a while.\n\t */\n\tprivate void removeUnseenRooms()\n\t{\n\t\tvar now = Instant.now();\n\t\tif (availableChatRooms.entrySet().removeIf(entry -> entry.getValue().getLastSeen().plus(CHATROOM_NEARBY_TIMEOUT).isBefore(now)))\n\t\t{\n\t\t\trefreshChatRoomsInClients();\n\t\t}\n\t}\n\n\t/**\n\t * Asks a peer for the list of chat rooms he's subscribed to.\n\t *\n\t * @param peerConnection the peer\n\t */\n\tprivate void askForNearbyChatRooms(PeerConnection peerConnection)\n\t{\n\t\tlog.debug(\"Asking for nearby chat rooms...\");\n\t\tpeerConnectionManager.writeItem(peerConnection, new ChatRoomListRequestItem(), this);\n\t}\n\n\t/**\n\t * Sends a keep alive event to the room. Allows other users to know we're in it.\n\t *\n\t * @param chatRoom the chat room\n\t */\n\tprivate void sendKeepAliveIfNeeded(ChatRoom chatRoom)\n\t{\n\t\tvar now = Instant.now();\n\n\t\tif (Duration.between(chatRoom.getLastKeepAlivePacket(), now).compareTo(KEEPALIVE_DELAY) > 0)\n\t\t{\n\t\t\tlog.debug(\"Sending keepalive event to chatroom {}\", chatRoom);\n\t\t\tsendChatRoomEvent(chatRoom, ChatRoomEvent.KEEP_ALIVE);\n\t\t\tchatRoom.setLastKeepAlivePacket(now);\n\t\t}\n\t}\n\n\t/**\n\t * Sends a connection challenge. Can be used to know if the peer is relaying a private room that we're also subscribed to.\n\t *\n\t * @param chatRoom the chat room\n\t */\n\tprivate void sendConnectionChallengeIfNeeded(ChatRoom chatRoom)\n\t{\n\t\tif (chatRoom.getConnectionChallengeCountAndIncrease() > CONNECTION_CHALLENGE_COUNT_MIN &&\n\t\t\t\tDuration.between(chatRoom.getLastConnectionChallenge(), Instant.now()).compareTo(CONNECTION_CHALLENGE_MIN_DELAY) > 0)\n\t\t{\n\t\t\tchatRoom.resetConnectionChallengeCount();\n\n\t\t\tvar recentMessage = chatRoom.getMessageCache().getRecentMessage();\n\t\t\tif (recentMessage == 0)\n\t\t\t{\n\t\t\t\tlog.debug(\"No message in cache to send connection challenge to room {}. Not enough activity?\", chatRoom);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Send connection challenge to all connected friends\n\t\t\tlog.debug(\"Sending connection challenge for room {}\", chatRoom);\n\t\t\tpeerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, new ChatRoomConnectChallengeItem(peerConnection.getLocation().getLocationIdentifier(), chatRoom.getId(), recentMessage), this),\n\t\t\t\t\tthis);\n\t\t}\n\t}\n\n\t/**\n\t * Sends a join event so others can know we joined the chat room.\n\t *\n\t * @param chatRoom the chat room\n\t */\n\tprivate void sendJoinEventIfNeeded(ChatRoom chatRoom)\n\t{\n\t\tif (!chatRoom.isJoinedRoomPacketSent() && chatRoom.hasParticipatingLocations())\n\t\t{\n\t\t\tsendChatRoomEvent(chatRoom, ChatRoomEvent.PEER_JOINED);\n\t\t\tchatRoom.setJoinedRoomPacketSent(true);\n\t\t}\n\t}\n\n\t/**\n\t * Subscribes to all rooms that are saved in the database.\n\t */\n\tprivate void subscribeToAllSavedRooms()\n\t{\n\t\tlog.debug(\"Subscribing to all saved rooms...\");\n\n\t\tchatRoomService.getAllChatRoomsPendingToSubscribe().forEach(savedRoom -> {\n\t\t\tvar chatRoom = new ChatRoom(\n\t\t\t\t\tsavedRoom.getRoomId(),\n\t\t\t\t\tsavedRoom.getName(),\n\t\t\t\t\tsavedRoom.getTopic(),\n\t\t\t\t\tsavedRoom.getFlags().contains(RoomFlags.PUBLIC) ? RoomType.PUBLIC : RoomType.PRIVATE,\n\t\t\t\t\t1,\n\t\t\t\t\tsavedRoom.getFlags().contains(RoomFlags.PGP_SIGNED)\n\t\t\t);\n\t\t\tsavedRoom.getLocations().forEach(chatRoom::recordPreviouslyKnownLocation);\n\t\t\tavailableChatRooms.put(chatRoom.getId(), chatRoom);\n\t\t\tjoinChatRoom(chatRoom.getId());\n\t\t});\n\t\trefreshChatRoomsInClients();\n\t}\n\n\tprivate ChatRoomLists buildChatRoomLists()\n\t{\n\t\tvar chatRoomLists = new ChatRoomLists();\n\n\t\tchatRooms.forEach((_, chatRoom) -> chatRoomLists.addSubscribed(chatRoom.getAsRoomInfo()));\n\t\tavailableChatRooms.forEach((_, chatRoom) -> chatRoomLists.addAvailable(chatRoom.getAsRoomInfo()));\n\t\tinvitedChatRooms.forEach((_, chatRoom) -> {\n\t\t\tif (chatRoom.isPrivate()) // Public rooms can be invited to too, so skip them here\n\t\t\t{\n\t\t\t\tchatRoomLists.addAvailable(chatRoom.getAsRoomInfo());\n\t\t\t}\n\t\t});\n\n\t\treturn chatRoomLists;\n\t}\n\n\tpublic ChatRoomContext getChatRoomContext()\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\t\treturn new ChatRoomContext(buildChatRoomLists(), new ChatRoomUser(ownIdentity.getName(), ownIdentity.getGxsId(), ownIdentity.getId()));\n\t\t}\n\t}\n\n\t/**\n\t * Handles the reception of the list of chat room the peer is subscribed to.\n\t *\n\t * @param peerConnection the peer\n\t * @param item           the ChatRoomListItem\n\t */\n\tprivate void handleChatRoomListItem(PeerConnection peerConnection, ChatRoomListItem item)\n\t{\n\t\tlog.debug(\"Received chat room list from {}: {}\", peerConnection, item);\n\t\tif (item.getChatRooms().size() > CHATROOM_LIST_MAX)\n\t\t{\n\t\t\tlog.warn(\"Location {} is sending a chat room list of {} items, which is bigger than the allowed {}\", peerConnection, item.getChatRooms().size(), CHATROOM_LIST_MAX);\n\t\t}\n\t\titem.getChatRooms().stream()\n\t\t\t\t.limit(CHATROOM_LIST_MAX)\n\t\t\t\t.forEach(itemRoom -> {\n\t\t\t\t\tvar chatRoom = availableChatRooms.getOrDefault(itemRoom.getId(), new ChatRoom(\n\t\t\t\t\t\t\titemRoom.getId(),\n\t\t\t\t\t\t\titemRoom.getName(),\n\t\t\t\t\t\t\titemRoom.getTopic(),\n\t\t\t\t\t\t\titemRoom.getFlags().contains(RoomFlags.PUBLIC) ? RoomType.PUBLIC : RoomType.PRIVATE,\n\t\t\t\t\t\t\titemRoom.getCount(), // XXX: we should update current chatroom with max(current_count, remote_count)\n\t\t\t\t\t\t\titemRoom.getFlags().contains(RoomFlags.PGP_SIGNED)));\n\n\t\t\t\t\t// If we're subscribed to the chat room but the friend is not participating, invite him\n\t\t\t\t\tif (chatRoom.addParticipatingLocation(peerConnection.getLocation()) && chatRooms.containsKey(chatRoom.getId()))\n\t\t\t\t\t{\n\t\t\t\t\t\tinviteLocationToChatRoom(peerConnection.getLocation(), chatRoom, Invitation.PLAIN);\n\t\t\t\t\t}\n\t\t\t\t\tupdateRooms(chatRoom);\n\t\t\t\t\tchatRoomService.getAllChatRoomsPendingToSubscribe().stream()\n\t\t\t\t\t\t\t.filter(pendingChatRoom -> pendingChatRoom.getRoomId() == chatRoom.getId())\n\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t.ifPresent(pendingChatRoom -> joinChatRoom(pendingChatRoom.getRoomId()));\n\t\t\t\t});\n\n\t\trefreshChatRoomsInClients();\n\t}\n\n\tprivate void updateRooms(ChatRoom chatRoom)\n\t{\n\t\tchatRoom.updateLastSeen();\n\t\tavailableChatRooms.put(chatRoom.getId(), chatRoom);\n\t\tchatRooms.replace(chatRoom.getId(), chatRoom);\n\t}\n\n\tprivate void refreshChatRoomsInClients()\n\t{\n\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_LIST, buildChatRoomLists());\n\t}\n\n\tprivate void handleChatRoomListRequestItem(PeerConnection peerConnection)\n\t{\n\t\tvar chatRoomListItem = new ChatRoomListItem(chatRooms.values().stream()\n\t\t\t\t.filter(chatRoom -> chatRoom.isPublic()\n\t\t\t\t\t\t|| chatRoom.isPreviouslyKnownLocation(peerConnection.getLocation())\n\t\t\t\t\t\t|| chatRoom.getParticipatingLocations().contains(peerConnection.getLocation()))\n\t\t\t\t.map(ChatRoom::getAsVisibleChatRoomInfo)\n\t\t\t\t.toList());\n\n\t\tlog.debug(\"Received chat room list request from {}, sending back {}\", peerConnection, chatRoomListItem);\n\n\t\tpeerConnectionManager.writeItem(peerConnection, chatRoomListItem, this);\n\t}\n\n\tprivate void handleChatRoomMessageItem(PeerConnection peerConnection, ChatRoomMessageItem item)\n\t{\n\t\tlog.debug(\"Received chat room message from peer {}: {}\", peerConnection, item);\n\n\t\tif (!validateExpiration(item.getSendTime()))\n\t\t{\n\t\t\tlog.warn(\"Received chat room message from peer {} failed time validation, dropping\", peerConnection);\n\t\t}\n\n\t\tif (!validateAndBounceItem(peerConnection, item))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar chatRoom = chatRooms.get(item.getRoomId());\n\n\t\t// And display the message for us\n\t\tvar user = item.getSignature().getGxsId();\n\t\tchatRoom.userActivity(user);\n\t\tvar message = parseIncomingText(item.getMessage());\n\t\tchatBacklogService.storeIncomingChatRoomMessage(item.getRoomId(), user, item.getSenderNickname(), message);\n\t\tscriptService.sendEvent(\"chatRoomMessage\", Map.of(\n\t\t\t\t\"roomId\", item.getRoomId(),\n\t\t\t\t\"gxsId\", user,\n\t\t\t\t\"nickname\", item.getSenderNickname(),\n\t\t\t\t\"content\", message));\n\t\tsendChatRoomMessageToConsumers(item.getRoomId(), user, item.getSenderNickname(), message);\n\n\t\tchatRoom.incrementConnectionChallengeCount();\n\t}\n\n\tprivate void handleChatRoomEventItem(PeerConnection peerConnection, ChatRoomEventItem item)\n\t{\n\t\tlog.debug(\"Received chat room event item from peer {}: {}\", peerConnection, item);\n\n\t\tif (!validateExpiration(item.getSendTime()))\n\t\t{\n\t\t\tlog.warn(\"Received chat room event from peer {} failed time validation, dropping\", peerConnection);\n\t\t}\n\n\t\tif (!validateAndBounceItem(peerConnection, item))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\t// XXX: add routing clue\n\t\tvar chatRoom = chatRooms.get(item.getRoomId());\n\t\tvar user = item.getSignature().getGxsId();\n\n\t\tif (item.getEventType() == ChatRoomEvent.PEER_LEFT.getCode())\n\t\t{\n\t\t\tchatRoom.removeUser(user);\n\t\t\tscriptService.sendEvent(\"chatRoomLeave\", Map.of(\n\t\t\t\t\t\"roomId\", item.getRoomId(),\n\t\t\t\t\t\"gxsId\", user,\n\t\t\t\t\t\"nickname\", item.getSenderNickname()\n\t\t\t));\n\t\t\tsendChatRoomEventToConsumers(item.getRoomId(), CHAT_ROOM_USER_LEAVE, user, item.getSenderNickname());\n\t\t}\n\t\telse if (item.getEventType() == ChatRoomEvent.PEER_JOINED.getCode())\n\t\t{\n\t\t\tchatRoom.addUser(user);\n\t\t\tscriptService.sendEvent(\"chatRoomJoin\", Map.of(\n\t\t\t\t\t\"roomId\", item.getRoomId(),\n\t\t\t\t\t\"gxsId\", user,\n\t\t\t\t\t\"nickname\", item.getSenderNickname()\n\t\t\t));\n\t\t\tsendChatRoomEventToConsumers(item.getRoomId(), CHAT_ROOM_USER_JOIN, user, item.getSenderNickname(), identityManager.getGxsGroup(peerConnection, user));\n\t\t\tchatRoom.setLastKeepAlivePacket(Instant.EPOCH); // send a keep alive event to the participant so that he knows we are in the room\n\t\t}\n\t\telse if (item.getEventType() == ChatRoomEvent.KEEP_ALIVE.getCode())\n\t\t{\n\t\t\tchatRoom.addUser(user); // KEEP_ALIVE is also used to add users\n\t\t\tsendChatRoomEventToConsumers(item.getRoomId(), CHAT_ROOM_USER_KEEP_ALIVE, user, item.getSenderNickname(), identityManager.getGxsGroup(peerConnection, user));\n\t\t}\n\t\telse if (item.getEventType() == ChatRoomEvent.PEER_STATUS.getCode())\n\t\t{\n\t\t\tchatRoom.userActivity(user);\n\t\t\tsendChatRoomTypingNotificationToConsumers(item.getRoomId(), user, item.getSenderNickname());\n\t\t}\n\t}\n\n\tprivate void sendChatRoomEventToConsumers(long roomId, MessageType messageType, GxsId gxsId, String nickname, IdentityGroupItem identityGroupItem)\n\t{\n\t\tvar chatRoomUserEvent = new ChatRoomUserEvent(gxsId, nickname, identityGroupItem != null ? identityGroupItem.getId() : 0L);\n\t\tmessageService.sendToConsumers(chatRoomDestination(), messageType, roomId, chatRoomUserEvent);\n\t}\n\n\tprivate void sendChatRoomEventToConsumers(long roomId, MessageType messageType, GxsId gxsId, String nickname)\n\t{\n\t\tsendChatRoomEventToConsumers(roomId, messageType, gxsId, nickname, null);\n\t}\n\n\tprivate void sendChatRoomTypingNotificationToConsumers(long roomId, GxsId gxsId, String nickname)\n\t{\n\t\tvar chatRoomMessage = new ChatRoomMessage(nickname, gxsId, null);\n\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_TYPING_NOTIFICATION, roomId, chatRoomMessage);\n\t}\n\n\tprivate void sendChatRoomTimeoutToConsumers(long roomId, GxsId gxsId, boolean split)\n\t{\n\t\tvar chatRoomTimeoutEvent = new ChatRoomTimeoutEvent(gxsId, split);\n\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_USER_TIMEOUT, roomId, chatRoomTimeoutEvent);\n\t}\n\n\tprivate void sendChatRoomMessageToConsumers(long roomId, GxsId gxsId, String nickname, String content)\n\t{\n\t\tvar chatRoomMessage = new ChatRoomMessage(nickname, gxsId, content);\n\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_MESSAGE, roomId, chatRoomMessage);\n\t}\n\n\tprivate void sendInviteToClient(LocationIdentifier locationIdentifier, long roomId, String roomName, String roomTopic)\n\t{\n\t\tif (invitedChatRooms.containsKey(roomId))\n\t\t{\n\t\t\treturn; // Don't show multiple requesters\n\t\t}\n\t\tvar chatRoomInvite = new ChatRoomInviteEvent(locationIdentifier.toString(), roomName, roomTopic);\n\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_INVITE, roomId, chatRoomInvite);\n\t}\n\n\t@SuppressWarnings(\"BooleanMethodIsAlwaysInverted\")\n\tprivate boolean validateAndBounceItem(PeerConnection peerConnection, ChatRoomBounce item)\n\t{\n\t\tif (!chatRooms.containsKey(item.getRoomId()))\n\t\t{\n\t\t\tlog.error(\"We're not subscribed to chat room id {}, dropping item {}\", log.isErrorEnabled() ? Id.toStringLowerCase(item.getRoomId()) : null, item);\n\t\t\treturn false;\n\t\t}\n\n\t\tif (isBanned(item.getSignature().getGxsId()))\n\t\t{\n\t\t\tlog.debug(\"Dropping item from banned entity {}\", item.getSignature().getGxsId());\n\t\t\treturn false;\n\t\t}\n\n\t\tif (!validateBounceSignature(peerConnection, item))\n\t\t{\n\t\t\tlog.error(\"Invalid signature for item {} from peer {}, gxsId: {}, dropping\", item, peerConnection, item.getSignature().getGxsId());\n\t\t\treturn false;\n\t\t}\n\n\t\t// XXX: add routing clue (ie. best peer for channel)\n\n\t\treturn bounce(peerConnection, item);\n\t}\n\n\tprivate void handleChatRoomUnsubscribeItem(PeerConnection peerConnection, ChatRoomUnsubscribeItem item)\n\t{\n\t\tlog.debug(\"Received unsubscribe item from {}: {}\", peerConnection, item);\n\t\tvar chatRoom = chatRooms.get(item.getRoomId());\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tlog.error(\"Cannot unsubscribe peer from chat room {} as we're not in it\", log.isErrorEnabled() ? Id.toStringLowerCase(item.getRoomId()) : null);\n\t\t\treturn;\n\t\t}\n\n\t\tchatRoom.removeParticipatingLocation(peerConnection.getLocation());\n\t\tchatRoom.recordPreviouslyKnownLocation(peerConnection.getLocation());\n\t}\n\n\tprivate void handleChatRoomInviteOldItem(PeerConnection peerConnection, ChatRoomInviteOldItem item)\n\t{\n\t\tlog.debug(\"Received deprecated invite from {}: {}\", peerConnection, item);\n\t\t// We do nothing because current RS sends that event for compatibility\n\t}\n\n\tprivate void handleChatRoomInviteItem(PeerConnection peerConnection, ChatRoomInviteItem item)\n\t{\n\t\tlog.debug(\"Received invite from {}: {}\", peerConnection, item);\n\n\t\tvar chatRoom = chatRooms.get(item.getRoomId());\n\t\tif (chatRoom != null)\n\t\t{\n\t\t\tif (!item.isConnectionChallenge() && (chatRoom.isPublic() != item.isPublic() || chatRoom.isSigned() != item.isSigned()))\n\t\t\t{\n\t\t\t\tlog.debug(\"Not a matching item\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlog.debug(\"Adding peer {} to chat room {}\", peerConnection, chatRoom);\n\n\t\t\tchatRoom.addParticipatingLocation(peerConnection.getLocation());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (!item.isConnectionChallenge())\n\t\t\t{\n\t\t\t\tlog.debug(\"Chat room invite, prompting user...\");\n\n\t\t\t\tvar invitedChatRoom = new ChatRoom(\n\t\t\t\t\t\titem.getRoomId(),\n\t\t\t\t\t\titem.getRoomName(),\n\t\t\t\t\t\titem.getRoomTopic(),\n\t\t\t\t\t\titem.isPublic() ? RoomType.PUBLIC : RoomType.PRIVATE,\n\t\t\t\t\t\t1,\n\t\t\t\t\t\titem.isSigned());\n\n\t\t\t\tinvitedChatRoom.addParticipatingLocation(peerConnection.getLocation());\n\t\t\t\tsendInviteToClient(peerConnection.getLocation().getLocationIdentifier(), item.getRoomId(), item.getRoomName(), item.getRoomTopic());\n\n\t\t\t\tinvitedChatRooms.put(invitedChatRoom.getId(), invitedChatRoom);\n\n\t\t\t\trefreshChatRoomsInClients();\n\t\t\t\tscriptService.sendEvent(\"chatRoomInvite\", Map.of(\n\t\t\t\t\t\t\"location\", peerConnection.getLocation().getLocationIdentifier().toString(),\n\t\t\t\t\t\t\"roomId\", item.getRoomId(),\n\t\t\t\t\t\t\"roomName\", item.getRoomName(),\n\t\t\t\t\t\t\"roomTopic\", item.getRoomTopic(),\n\t\t\t\t\t\t\"roomIsPublic\", item.isPublic(),\n\t\t\t\t\t\t\"roomUserCount\", 1,\n\t\t\t\t\t\t\"roomIsSigned\", item.isSigned()\n\t\t\t\t));\n\t\t\t}\n\t\t}\n\t}\n\n\t@SuppressWarnings(\"StatementWithEmptyBody\")\n\tprivate void handleChatStatusItem(PeerConnection peerConnection, ChatStatusItem item)\n\t{\n\t\t// There's a whole protocol with the flags (REQUEST_CUSTOM_STATE, CUSTOM_STATE and CUSTOM_STATE_AVAILABLE)\n\t\t// to change the status string; but it seems all RS does is send the typing state every\n\t\t// 5 seconds while the user is typing.\n\t\tif (item.getFlags().contains(ChatFlags.REQUEST_CUSTOM_STATE))\n\t\t{\n\t\t\t// XXX: send the custom string\n\t\t}\n\t\telse if (item.getFlags().contains(ChatFlags.CUSTOM_STATE))\n\t\t{\n\t\t\t// XXX: store the string\n\t\t}\n\t\telse if (item.getFlags().contains(ChatFlags.CUSTOM_STATE_AVAILABLE))\n\t\t{\n\t\t\t// XXX: send a custom string request\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Got status item from peer {}: {}\", peerConnection, item);\n\t\t\tif (MESSAGE_TYPING_CONTENT.equals(item.getStatus()))\n\t\t\t{\n\t\t\t\tmessageService.sendToConsumers(chatPrivateDestination(), CHAT_TYPING_NOTIFICATION, peerConnection.getLocation().getLocationIdentifier(), new ChatMessage());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Unknown status item from peer {}, status: {}, flags: {}\", peerConnection, item.getStatus(), item.getFlags());\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void handleChatStatusItem(DistantLocation distantLocation, ChatStatusItem item)\n\t{\n\t\tlog.debug(\"Got status item from distant peer {}: {}\", distantLocation, item);\n\t\tif (MESSAGE_TYPING_CONTENT.equals(item.getStatus()))\n\t\t{\n\t\t\tmessageService.sendToConsumers(chatDistantDestination(), CHAT_TYPING_NOTIFICATION, distantLocation.getGxsId(), new ChatMessage());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.warn(\"Unknown status item from distant peer {}, status: {}, flags: {}\", distantLocation, item.getStatus(), item.getFlags());\n\t\t}\n\t}\n\n\tprivate void handleChatMessageItem(PeerConnection peerConnection, ChatMessageItem item)\n\t{\n\t\tlog.debug(\"Received chat message item from {}: {}\", peerConnection, item);\n\t\tif (item.isPrivate())\n\t\t{\n\t\t\tif (item.isAvatarRequest())\n\t\t\t{\n\t\t\t\thandleAvatarRequest(peerConnection);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (item.isPartial())\n\t\t\t\t{\n\t\t\t\t\thandlePartialMessage(peerConnection, item);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\thandleMessage(peerConnection, item);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse if (item.isBroadcast())\n\t\t{\n\t\t\tuiBridgeService.showTrayNotification(BROADCAST, \"Broadcast from \" + peerConnection.getLocation().getProfile().getName() + \"@\" + peerConnection.getLocation().getSafeName() + \": \" + parseIncomingText(item.getMessage()));\n\t\t}\n\t}\n\n\tprivate void handleChatMessageItem(DistantLocation distantLocation, ChatMessageItem item)\n\t{\n\t\tlog.debug(\"Received distant chat message item from {}: {}\", distantLocation, item);\n\t\tif (!item.isPrivate())\n\t\t{\n\t\t\tlog.debug(\"Item type {} not supported\", item);\n\t\t\treturn;\n\t\t}\n\n\t\tif (item.isAvatarRequest())\n\t\t{\n\t\t\thandleAvatarRequest(distantLocation);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (item.isPartial())\n\t\t\t{\n\t\t\t\thandlePartialMessage(distantLocation, item);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\thandleMessage(distantLocation, item);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void handleAvatarRequest(PeerConnection peerConnection)\n\t{\n\t\tvar ownImage = getOwnImage();\n\t\tif (ownImage != null)\n\t\t{\n\t\t\tpeerConnectionManager.writeItem(peerConnection, new ChatAvatarItem(ownImage), this);\n\t\t}\n\t}\n\n\tprivate void handleAvatarRequest(DistantLocation distantLocation)\n\t{\n\t\tvar ownImage = getOwnImage();\n\t\tif (ownImage != null)\n\t\t{\n\t\t\tgxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, ItemUtils.serializeItem(new ChatAvatarItem(ownImage), this));\n\t\t}\n\t}\n\n\tprivate byte[] getOwnImage()\n\t{\n\t\tvar ownImage = identityService.getOwnIdentity().getImage();\n\t\tif (ownImage != null && ownImage.length <= AVATAR_SIZE_MAX)\n\t\t{\n\t\t\treturn ownImage;\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate void handleMessage(PeerConnection peerConnection, ChatMessageItem item)\n\t{\n\t\tvar message = item.getMessage();\n\t\tvar messageList = peerConnection.getServiceData(this, KEY_PARTIAL_MESSAGE_LIST);\n\t\tif (messageList.isPresent())\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar existingList = (List<String>) messageList.get();\n\t\t\texistingList.add(message);\n\t\t\tmessage = String.join(\"\", existingList);\n\t\t\tpeerConnection.removeServiceData(this, KEY_PARTIAL_MESSAGE_LIST);\n\t\t}\n\t\tvar from = peerConnection.getLocation().getLocationIdentifier();\n\t\tvar chatMessage = new ChatMessage(parseIncomingText(message));\n\t\tchatBacklogService.storeIncomingMessage(from, chatMessage.getContent());\n\t\tscriptService.sendEvent(\"chatPrivateMessage\", Map.of(\n\t\t\t\t\"location\", from.toString(),\n\t\t\t\t\"content\", chatMessage.getContent()\n\t\t));\n\t\tmessageService.sendToConsumers(chatPrivateDestination(), CHAT_PRIVATE_MESSAGE, from, chatMessage);\n\t}\n\n\tprivate void handleMessage(DistantLocation distantLocation, ChatMessageItem item)\n\t{\n\t\tvar message = item.getMessage();\n\t\tif (distantLocation.hasMessages())\n\t\t{\n\t\t\tdistantLocation.addMessage(message);\n\t\t\tmessage = distantLocation.getAllMessages();\n\t\t\tdistantLocation.clearMessages();\n\t\t}\n\t\tvar from = distantLocation.getGxsId();\n\t\tvar chatMessage = new ChatMessage(parseIncomingText(message));\n\t\tchatBacklogService.storeIncomingDistantMessage(from, chatMessage.getContent());\n\t\tscriptService.sendEvent(\"chatDistantMessage\", Map.of(\n\t\t\t\t\"gxsId\", from.toString(),\n\t\t\t\t\"content\", chatMessage.getContent()\n\t\t));\n\t\tmessageService.sendToConsumers(chatDistantDestination(), CHAT_PRIVATE_MESSAGE, from, chatMessage);\n\t}\n\n\tprivate void handlePartialMessage(PeerConnection peerConnection, ChatMessageItem item)\n\t{\n\t\tvar messageList = peerConnection.getServiceData(this, KEY_PARTIAL_MESSAGE_LIST);\n\t\tif (messageList.isEmpty())\n\t\t{\n\t\t\tList<String> newMessageList = new ArrayList<>();\n\t\t\tnewMessageList.add(item.getMessage());\n\t\t\tpeerConnection.putServiceData(this, KEY_PARTIAL_MESSAGE_LIST, newMessageList);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t//noinspection unchecked\n\t\t\t((List<String>) messageList.get()).add(item.getMessage());\n\t\t}\n\t}\n\n\tprivate void handlePartialMessage(DistantLocation distantLocation, ChatMessageItem item)\n\t{\n\t\tdistantLocation.addMessage(item.getMessage());\n\t}\n\n\tprivate void handleChatAvatarItem(PeerConnection peerConnection, ChatAvatarItem item)\n\t{\n\t\tif (!isAvatarValid(item))\n\t\t{\n\t\t\tlog.debug(\"Avatar from {} is null or too big\", peerConnection);\n\t\t\treturn;\n\t\t}\n\n\t\tvar chatAvatar = new ChatAvatar(item.getImageData());\n\t\tmessageService.sendToConsumers(chatPrivateDestination(), CHAT_AVATAR, peerConnection.getLocation().getLocationIdentifier(), chatAvatar);\n\t}\n\n\tprivate void handleChatAvatarItem(DistantLocation distantLocation, ChatAvatarItem item)\n\t{\n\t\tif (!isAvatarValid(item))\n\t\t{\n\t\t\tlog.debug(\"Distant avatar from {} is null or too big\", distantLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tvar chatAvatar = new ChatAvatar(item.getImageData());\n\t\tmessageService.sendToConsumers(chatDistantDestination(), CHAT_AVATAR, distantLocation.getGxsId(), chatAvatar);\n\t}\n\n\tprivate boolean isAvatarValid(ChatAvatarItem item)\n\t{\n\t\treturn item.getImageData() != null && item.getImageData().length <= AVATAR_SIZE_MAX;\n\t}\n\n\t/**\n\t * Allows to know if a peer is participating in a private chat room and if it is, add it as participating in the room.\n\t * For example A, B and C are connected together. If B sends a challenge to A, and it matches (because B is connected through C), A will know that B is on that private\n\t * channel and can forward directly to it.\n\t *\n\t * @param peerConnection the peer connection\n\t * @param item           the challenge item\n\t */\n\tprivate void handleChatRoomConnectChallengeItem(PeerConnection peerConnection, ChatRoomConnectChallengeItem item)\n\t{\n\t\tlog.debug(\"Received chat room connect challenge from {}: {}\", peerConnection, item);\n\t\tvar locationIdentifier = peerConnection.getLocation().getLocationIdentifier();\n\n\t\tchatRooms.values().stream()\n\t\t\t\t.filter(chatRoom -> chatRoom.getMessageCache().hasConnectionChallenge(locationIdentifier, chatRoom.getId(), item.getChallengeCode()))\n\t\t\t\t.findAny()\n\t\t\t\t.ifPresent(chatRoom -> {\n\t\t\t\t\tlog.debug(\"Challenge accepted for chatroom {}, sending connection request to peer {}\", chatRoom, peerConnection);\n\t\t\t\t\tchatRoom.addParticipatingLocation(peerConnection.getLocation());\n\t\t\t\t\tinviteLocationToChatRoom(peerConnection.getLocation(), chatRoom, Invitation.FROM_CHALLENGE);\n\t\t\t\t});\n\t}\n\n\tprivate void inviteLocationToChatRoom(Location location, ChatRoom chatRoom, Invitation invitation)\n\t{\n\t\tlog.debug(\"Invite location {} to chatRoom {} with invitation {}\", location, chatRoom, invitation);\n\t\tvar item = new ChatRoomInviteItem(\n\t\t\t\tchatRoom.getId(),\n\t\t\t\tchatRoom.getName(),\n\t\t\t\tchatRoom.getTopic(),\n\t\t\t\tinvitation == Invitation.FROM_CHALLENGE ? EnumSet.of(RoomFlags.CHALLENGE) : chatRoom.getRoomFlags());\n\t\tpeerConnectionManager.writeItem(location, item, this);\n\t}\n\n\tprivate void signalChatRoomLeave(Location location, ChatRoom chatRoom)\n\t{\n\t\tvar item = new ChatRoomUnsubscribeItem(chatRoom.getId());\n\t\tpeerConnectionManager.writeItem(location, item, this);\n\t}\n\n\tprivate void initializeBounce(ChatRoom chatRoom, ChatRoomBounce bounce)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar ownIdentity = identityService.getOwnIdentity();\n\n\t\t\tbounce.setRoomId(chatRoom.getId());\n\t\t\tbounce.setMessageId(chatRoom.getNewMessageId());\n\t\t\tbounce.setSenderNickname(ownIdentity.getName()); // XXX: we should use the identity in chatRoom.getGxsId() once we have multiple identities support done properly\n\n\t\t\tvar signature = identityService.signData(ownIdentity, getBounceData(bounce));\n\n\t\t\tbounce.setSignature(new Signature(ownIdentity.getGxsId(), signature));\n\t\t}\n\t}\n\n\tprivate boolean bounce(ChatRoomBounce bounce)\n\t{\n\t\treturn bounce(null, bounce);\n\t}\n\n\tprivate boolean bounce(PeerConnection peerConnection, ChatRoomBounce bounce)\n\t{\n\t\tvar chatRoom = chatRooms.get(bounce.getRoomId());\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tlog.error(\"Can't send to chat room {}, we're not subscribed to it\", log.isErrorEnabled() ? Id.toStringLowerCase(bounce.getRoomId()) : null);\n\t\t\treturn false;\n\t\t}\n\n\t\tif (peerConnection != null)\n\t\t{\n\t\t\tchatRoom.addParticipatingLocation(peerConnection.getLocation()); // If we didn't receive the list yet, it means he's participating still\n\t\t}\n\n\t\tif (chatRoom.getMessageCache().exists(bounce.getMessageId()))\n\t\t{\n\t\t\tlog.debug(\"Message id {} already received, dropping\", bounce.getMessageId());\n\t\t\treturn false;\n\t\t}\n\n\t\tchatRoom.getMessageCache().add(bounce.getMessageId());\n\t\tchatRoom.updateActivity();\n\n\t\t// XXX: check for antiflood\n\n\t\t// Send to everyone except the originating peer\n\t\tvar iterator = chatRoom.getParticipatingLocations().iterator();\n\t\twhile (iterator.hasNext())\n\t\t{\n\t\t\tvar location = iterator.next();\n\t\t\tif (peerConnection == null || !Objects.equals(location, peerConnection.getLocation()))\n\t\t\t{\n\t\t\t\tvar status = peerConnectionManager.writeItem(location, bounce.clone(), this); // Netty frees sent items so we need to clone\n\t\t\t\tif (status.isDone() && !status.isSuccess())\n\t\t\t\t{\n\t\t\t\t\titerator.remove(); // Failed to write, it means the location disconnected, so we need to remove it from our participating locations\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tchatRoom.incrementConnectionChallengeCount();\n\n\t\treturn true;\n\t}\n\n\tprivate boolean validateBounceSignature(PeerConnection peerConnection, ChatRoomBounce bounce)\n\t{\n\t\tvar gxsGroup = identityManager.getGxsGroup(peerConnection, bounce.getSignature().getGxsId());\n\t\tif (gxsGroup != null)\n\t\t{\n\t\t\tvar publicKey = gxsGroup.getAdminPublicKey();\n\t\t\tif (publicKey == null)\n\t\t\t{\n\t\t\t\tlog.debug(\"{} has no public admin key, not validating\", bounce.getSenderNickname());\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\treturn RSA.verify(publicKey, bounce.getSignature().getData(), getBounceData(bounce));\n\t\t}\n\t\tlog.debug(\"No key yet for verification, passing through\");\n\t\treturn true; // if we don't have the identity yet, we let the item pass because it could be valid, and it's impossible to impersonate an identity this way\n\t}\n\n\tprivate static boolean isBanned(GxsId gxsId)\n\t{\n\t\t// XXX: implement by using the reputation level\n\t\treturn false;\n\t}\n\n\tprivate byte[] getBounceData(ChatRoomBounce chatRoomBounce)\n\t{\n\t\treturn ItemUtils.serializeItemForSignature(chatRoomBounce, this);\n\t}\n\n\t/**\n\t * Checks if a message is well within our own time.\n\t *\n\t * @param sendTime the time the message was sent at, in seconds from 1970-01-01 UTC\n\t * @return true if within bounds\n\t */\n\t@SuppressWarnings(\"BooleanMethodIsAlwaysInverted\")\n\tprivate static boolean validateExpiration(int sendTime)\n\t{\n\t\tvar now = Instant.now();\n\t\tif (sendTime < now.getEpochSecond() + TIME_DRIFT_PAST_MAX.toSeconds() - KEEP_MESSAGE_RECORD_MAX.toSeconds())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\t//noinspection RedundantIfStatement\n\t\tif (sendTime > now.getEpochSecond() + TIME_DRIFT_FUTURE_MAX.toSeconds())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\treturn true;\n\t}\n\n\t/**\n\t * Sends a broadcast message to all connected peers.\n\t *\n\t * @param message the message\n\t */\n\tpublic void sendBroadcastMessage(String message)\n\t{\n\t\tvar chatMessageItem = new ChatMessageItem(message, EnumSet.of(ChatFlags.PUBLIC));\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, chatMessageItem, this),\n\t\t\t\tthis);\n\t}\n\n\t/**\n\t * Sends a private message to a peer.\n\t *\n\t * @param identifier the identifier (LocationIdentifier or GxsId)\n\t * @param message    the message\n\t */\n\tpublic void sendPrivateMessage(Identifier identifier, String message)\n\t{\n\t\tswitch (identifier)\n\t\t{\n\t\t\tcase LocationIdentifier locationIdentifier -> sendPrivateMessageToLocation(locationIdentifier, message);\n\t\t\tcase GxsId gxsId -> sendPrivateMessageToGxsId(gxsId, message);\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + identifier);\n\t\t}\n\t}\n\n\tprivate void sendPrivateMessageToLocation(LocationIdentifier locationIdentifier, String message)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow();\n\t\tchatBacklogService.storeOutgoingMessage(location.getLocationIdentifier(), message);\n\t\tpeerConnectionManager.writeItem(location, new ChatMessageItem(message, EnumSet.of(ChatFlags.PRIVATE)), this);\n\t}\n\n\tprivate void sendPrivateMessageToGxsId(GxsId gxsId, String message)\n\t{\n\t\tvar distantLocation = distantChatContacts.get(gxsId);\n\t\tif (distantLocation == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find distantLocation for gxsId {} when sending private message\", gxsId);\n\t\t\treturn;\n\t\t}\n\n\t\tvar identity = identityService.findByGxsId(gxsId).orElseThrow();\n\t\tchatBacklogService.storeOutgoingDistantMessage(identity.getGxsId(), message);\n\t\tvar data = ItemUtils.serializeItem(new ChatMessageItem(message, EnumSet.of(ChatFlags.PRIVATE)), this);\n\t\tgxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, data);\n\t}\n\n\t/**\n\t * Sends a typing notification for private messages to a peer.\n\t *\n\t * @param identifier the identifier\n\t */\n\tpublic void sendPrivateTypingNotification(Identifier identifier)\n\t{\n\t\tswitch (identifier)\n\t\t{\n\t\t\tcase LocationIdentifier locationIdentifier -> sendPrivateTypingNotificationToLocation(locationIdentifier);\n\t\t\tcase GxsId gxsId -> sendPrivateTypingNotificationToGxsId(gxsId);\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + identifier);\n\t\t}\n\t}\n\n\tprivate void sendPrivateTypingNotificationToLocation(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow();\n\t\tpeerConnectionManager.writeItem(location, new ChatStatusItem(MESSAGE_TYPING_CONTENT, EnumSet.of(ChatFlags.PRIVATE)), this);\n\t}\n\n\tprivate void sendPrivateTypingNotificationToGxsId(GxsId gxsId)\n\t{\n\t\tvar distantLocation = distantChatContacts.get(gxsId);\n\t\tif (distantLocation == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find distantLocation for gxsId {} when sending typing notification\", gxsId);\n\t\t\treturn;\n\t\t}\n\t\tvar data = ItemUtils.serializeItem(new ChatStatusItem(MESSAGE_TYPING_CONTENT, EnumSet.of(ChatFlags.PRIVATE)), this);\n\t\tgxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, data);\n\t}\n\n\tpublic void sendAvatarRequest(Identifier identifier)\n\t{\n\t\tswitch (identifier)\n\t\t{\n\t\t\tcase LocationIdentifier locationIdentifier -> sendAvatarRequestToLocation(locationIdentifier);\n\t\t\tcase GxsId gxsId -> sendAvatarRequestToGxsId(gxsId);\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + identifier);\n\t\t}\n\t}\n\n\tprivate void sendAvatarRequestToLocation(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow();\n\t\tpeerConnectionManager.writeItem(location, new ChatMessageItem(\"\", EnumSet.of(ChatFlags.PRIVATE, ChatFlags.REQUEST_AVATAR)), this);\n\t}\n\n\tprivate void sendAvatarRequestToGxsId(GxsId gxsId)\n\t{\n\t\tvar distantLocation = distantChatContacts.get(gxsId);\n\t\tif (distantLocation == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find distantLocation for gxsId: {} when sending avatar request\", gxsId);\n\t\t\treturn;\n\t\t}\n\t\tvar data = ItemUtils.serializeItem(new ChatMessageItem(\"\", EnumSet.of(ChatFlags.PRIVATE, ChatFlags.REQUEST_AVATAR)), this);\n\t\tgxsTunnelRsService.sendData(distantLocation.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID, data);\n\t}\n\n\tpublic Location createDistantChat(IdentityGroupItem identityGroupItem)\n\t{\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\tvar tunnelId = gxsTunnelRsService.requestSecuredTunnel(ownIdentity.getGxsId(), identityGroupItem.getGxsId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID);\n\t\tif (tunnelId != null)\n\t\t{\n\t\t\tlog.debug(\"Creating distant chat tunnel for identity {}, resulting tunnelId: {}\", identityGroupItem.getGxsId(), tunnelId.getLocationIdentifier());\n\t\t\tdistantChatContacts.put(identityGroupItem.getGxsId(), new DistantLocation(tunnelId, identityGroupItem.getGxsId()));\n\t\t}\n\t\treturn tunnelId;\n\t}\n\n\tpublic boolean closeDistantChat(IdentityGroupItem identityGroupItem)\n\t{\n\t\tvar location = distantChatContacts.remove(identityGroupItem.getGxsId());\n\t\tif (location == null)\n\t\t{\n\t\t\tlog.debug(\"Failed to close distant chat for identityGroupItem {}\", identityGroupItem);\n\t\t\treturn false;\n\t\t}\n\t\tgxsTunnelRsService.closeExistingTunnel(location.getTunnelId(), DISTANT_CHAT_GXS_TUNNEL_SERVICE_ID);\n\t\treturn true;\n\t}\n\n\t/**\n\t * Sets the status message (the one appearing at the top of the profile peer; for example, \"I'm eating\", \"Gone for a walk\", etc...).\n\t *\n\t * @param message the status message\n\t */\n\tpublic void setStatusMessage(String message)\n\t{\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, new ChatStatusItem(message, EnumSet.of(ChatFlags.CUSTOM_STATE)), this),\n\t\t\t\tthis);\n\t}\n\n\t/**\n\t * Sends a message to a chat room.\n\t *\n\t * @param chatRoomId the id of the chat room\n\t * @param message    the message\n\t */\n\tpublic void sendChatRoomMessage(long chatRoomId, String message)\n\t{\n\t\tvar chatRoomMessageItem = new ChatRoomMessageItem(message);\n\n\t\tvar chatRoom = chatRooms.get(chatRoomId);\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tlog.warn(\"Chatroom {} doesn't exist. Not sending the message.\", log.isWarnEnabled() ? Id.toStringLowerCase(chatRoomId) : null);\n\t\t\treturn;\n\t\t}\n\n\t\tinitializeBounce(chatRoom, chatRoomMessageItem);\n\t\tchatBacklogService.storeOutgoingChatRoomMessage(chatRoomId, chatRoomMessageItem.getSenderNickname(), message);\n\t\tbounce(chatRoomMessageItem);\n\t}\n\n\tpublic void sendChatRoomTypingNotification(long chatRoomId)\n\t{\n\t\tvar chatRoom = chatRooms.get(chatRoomId);\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tlog.warn(\"Chatroom {} doesn't exist. Not sending the typing notification.\", log.isWarnEnabled() ? Id.toStringLowerCase(chatRoomId) : null);\n\t\t\treturn;\n\t\t}\n\t\tsendChatRoomEvent(chatRoom, ChatRoomEvent.PEER_STATUS, MESSAGE_TYPING_CONTENT);\n\t}\n\n\t/**\n\t * Joins a chat room.\n\t *\n\t * @param chatRoomId the id of the chat room\n\t */\n\tpublic void joinChatRoom(long chatRoomId)\n\t{\n\t\tlog.debug(\"Joining chat room {}\", log.isDebugEnabled() ? Id.toStringLowerCase(chatRoomId) : null);\n\t\tif (chatRooms.containsKey(chatRoomId))\n\t\t{\n\t\t\tlog.debug(\"Already in the chatroom\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar chatRoom = getAvailableChatRoom(chatRoomId);\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tlog.warn(\"Chatroom {} doesn't exist, can't join.\", log.isWarnEnabled() ? Id.toStringLowerCase(chatRoomId) : null);\n\t\t\treturn;\n\t\t}\n\t\tchatRooms.put(chatRoomId, chatRoom);\n\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager)) // XXX: ugly, it's because we can be called from a lambda.. make it take the arguments later (needed for multi identity support)\n\t\t{\n\t\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\t\tchatRoom.setOwnGxsId(ownIdentity.getGxsId());\n\t\t\tchatRoomService.subscribeToChatRoomAndJoin(chatRoom, ownIdentity);\n\n\t\t\tchatRoom.getParticipatingLocations().forEach(location -> inviteLocationToChatRoom(location, chatRoom, Invitation.PLAIN));\n\n\t\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_JOIN, chatRoom.getId(), new ChatRoomMessage());\n\t\t\tchatRoom.addUser(ownIdentity.getGxsId());\n\n\t\t\tsendJoinEventIfNeeded(chatRoom);\n\n\t\t\t// Add ourselves in the UI so that we're shown as joining\n\t\t\tsendChatRoomEventToConsumers(chatRoom.getId(), CHAT_ROOM_USER_JOIN, ownIdentity.getGxsId(), ownIdentity.getName(), ownIdentity);\n\t\t}\n\t}\n\n\tprivate ChatRoom getAvailableChatRoom(long chatRoomId)\n\t{\n\t\tvar chatRoom = availableChatRooms.get(chatRoomId);\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tchatRoom = invitedChatRooms.remove(chatRoomId);\n\t\t}\n\t\treturn chatRoom;\n\t}\n\n\t/**\n\t * Leaves a chat room.\n\t *\n\t * @param chatRoomId the id of the chat room\n\t */\n\tpublic void leaveChatRoom(long chatRoomId)\n\t{\n\t\tlog.debug(\"Leaving chat room {}\", log.isDebugEnabled() ? Id.toStringLowerCase(chatRoomId) : null);\n\t\tvar chatRoomToRemove = chatRooms.remove(chatRoomId);\n\t\tif (chatRoomToRemove == null)\n\t\t{\n\t\t\tlog.debug(\"Can't leave a chatroom we aren't into\");\n\t\t\treturn;\n\t\t}\n\t\tchatRoomToRemove.clearUsers();\n\t\tsendChatRoomEvent(chatRoomToRemove, ChatRoomEvent.PEER_LEFT);\n\t\tchatRoomToRemove.setJoinedRoomPacketSent(false); // in the case we rejoin immediately\n\t\tchatRoomService.unsubscribeFromChatRoomAndLeave(chatRoomId, identityService.getOwnIdentity()); // XXX: allow multiple identities\n\n\t\tchatRoomToRemove.getParticipatingLocations().forEach(peer -> signalChatRoomLeave(peer, chatRoomToRemove));\n\t\tmessageService.sendToConsumers(chatRoomDestination(), CHAT_ROOM_LEAVE, chatRoomToRemove.getId(), new ChatRoomMessage());\n\t}\n\n\tpublic long createChatRoom(String roomName, String topic, Set<RoomFlags> flags, boolean signedIdentities)\n\t{\n\t\tvar newChatRoom = new ChatRoom(\n\t\t\t\tcreateUniqueRoomId(),\n\t\t\t\troomName,\n\t\t\t\ttopic,\n\t\t\t\tflags.contains(RoomFlags.PUBLIC) ? RoomType.PUBLIC : RoomType.PRIVATE,\n\t\t\t\t1,\n\t\t\t\tsignedIdentities);\n\n\t\tavailableChatRooms.put(newChatRoom.getId(), newChatRoom);\n\n\t\trefreshChatRoomsInClients();\n\n\t\tjoinChatRoom(newChatRoom.getId());\n\n\t\t// XXX: we could invite friends in there... supply a list of friends as parameter\n\n\t\treturn newChatRoom.getId();\n\t}\n\n\tpublic void inviteLocationsToChatRoom(long chatRoomId, Set<LocationIdentifier> ids)\n\t{\n\t\tvar chatRoom = chatRooms.get(chatRoomId);\n\t\tif (chatRoom == null)\n\t\t{\n\t\t\tlog.error(\"Cannot invite to unsubscribed chatroom {}\", chatRoomId);\n\t\t\treturn;\n\t\t}\n\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> {\n\t\t\tif (ids.contains(peerConnection.getLocation().getLocationIdentifier()))\n\t\t\t{\n\t\t\t\tinviteLocationToChatRoom(peerConnection.getLocation(), chatRoom, Invitation.PLAIN);\n\t\t\t}\n\t\t}, this);\n\t}\n\n\t@EventListener\n\tpublic void onPeerConnectedEvent(PeerConnectedEvent event)\n\t{\n\t\tmessageService.sendToConsumers(chatPrivateDestination(), CHAT_AVAILABILITY, event.locationIdentifier(), AVAILABLE);\n\t}\n\n\t@EventListener\n\tpublic void onPeerDisconnectedEvent(PeerDisconnectedEvent event)\n\t{\n\t\tmessageService.sendToConsumers(chatPrivateDestination(), CHAT_AVAILABILITY, event.locationIdentifier(), OFFLINE);\n\t}\n\n\tprivate long createUniqueRoomId()\n\t{\n\t\tlong newId;\n\n\t\tdo\n\t\t{\n\t\t\tnewId = SecureRandomUtils.nextLong();\n\t\t}\n\t\twhile (availableChatRooms.containsKey(newId) || chatRooms.containsKey(newId) || invitedChatRooms.containsKey(newId));\n\n\t\treturn newId;\n\t}\n\n\t/**\n\t * Send a chat room event to the participating peers.\n\t *\n\t * @param chatRoom the chat room\n\t * @param event    the event\n\t */\n\tprivate void sendChatRoomEvent(ChatRoom chatRoom, ChatRoomEvent event)\n\t{\n\t\tsendChatRoomEvent(chatRoom, event, \"\");\n\t}\n\n\t/**\n\t * Send a chat room event to the participating peers.\n\t *\n\t * @param chatRoom the chat room\n\t * @param event    the event\n\t * @param status   the status, if empty prefer {@linkplain #sendChatRoomEvent(ChatRoom, ChatRoomEvent) the overloaded alternative}\n\t */\n\tprivate void sendChatRoomEvent(ChatRoom chatRoom, ChatRoomEvent event, String status)\n\t{\n\t\tvar chatRoomEvent = new ChatRoomEventItem(event, status);\n\n\t\tinitializeBounce(chatRoom, chatRoomEvent);\n\t\tlog.debug(\"Sending chat room event {}\", chatRoomEvent);\n\t\tbounce(chatRoomEvent);\n\t}\n\n\tprivate String parseIncomingText(String text)\n\t{\n\t\treturn unHtmlService.cleanupMessage(text);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/DistantLocation.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.GxsId;\n\nclass DistantLocation\n{\n\tprivate final Location tunnelId;\n\tprivate final GxsId gxsId;\n\tprivate final List<String> messageList;\n\n\tpublic DistantLocation(Location tunnelId, GxsId gxsId)\n\t{\n\t\tthis.tunnelId = tunnelId;\n\t\tthis.gxsId = gxsId;\n\t\tmessageList = new ArrayList<>();\n\t}\n\n\tpublic Location getTunnelId()\n\t{\n\t\treturn tunnelId;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic boolean hasMessages()\n\t{\n\t\treturn !messageList.isEmpty();\n\t}\n\n\tpublic void addMessage(String message)\n\t{\n\t\tmessageList.add(message);\n\t}\n\n\tpublic String getAllMessages()\n\t{\n\t\treturn String.join(\"\", messageList);\n\t}\n\n\tpublic void clearMessages()\n\t{\n\t\tmessageList.clear();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"DistantLocation{\" +\n\t\t\t\t\"tunnelId=\" + tunnelId +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/MessageCache.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.crypto.hash.chat.ChatChallenge;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.util.SecureRandomUtils;\n\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\nclass MessageCache\n{\n\tprivate static final int CONNECTION_CHALLENGE_MAX_TIME = 30; // maximum age in seconds a message can be used in a connection challenge\n\tprivate static final int LIFETIME_MAX = 1200; // maximum age of a message in seconds\n\n\tprivate final Map<Long, Integer> messages = new ConcurrentHashMap<>();\n\n\n\t/**\n\t * Checks if a message has been recorded already. If yes, update\n\t * its own time to prevent echoes.\n\t *\n\t * @param id the id of the message to check\n\t * @return true if it exists\n\t */\n\tpublic boolean exists(long id)\n\t{\n\t\treturn messages.replace(id, (int) Instant.now().getEpochSecond()) != null;\n\t}\n\n\t/**\n\t * Adds a message id to the cache.\n\t *\n\t * @param id the message id\n\t */\n\tpublic void add(long id)\n\t{\n\t\tmessages.put(id, (int) Instant.now().getEpochSecond());\n\t}\n\n\t/**\n\t * Updates the time of a message id.\n\t *\n\t * @param id the message id\n\t */\n\tpublic void update(long id)\n\t{\n\t\tadd(id);\n\t}\n\n\t/**\n\t * Gets a new unique message id\n\t *\n\t * @return the message id\n\t */\n\tpublic long getNewMessageId()\n\t{\n\t\tlong newId;\n\n\t\tdo\n\t\t{\n\t\t\tnewId = SecureRandomUtils.nextLong();\n\t\t}\n\t\twhile (messages.containsKey(newId));\n\n\t\treturn newId;\n\t}\n\n\t/**\n\t * Checks if this message cache contains a challenge code.\n\t *\n\t * @param locationIdentifier    the location identifier of the peer\n\t * @param chatRoomId    the chat room id\n\t * @param challengeCode the challenge code to be matched against\n\t * @return true if challengeCode is in one of a suitable message\n\t */\n\tpublic boolean hasConnectionChallenge(LocationIdentifier locationIdentifier, long chatRoomId, long challengeCode)\n\t{\n\t\tvar now = (int) Instant.now().getEpochSecond();\n\n\t\tfor (var message : messages.entrySet())\n\t\t{\n\t\t\tif (message.getValue() + CONNECTION_CHALLENGE_MAX_TIME + 5 > now && challengeCode == ChatChallenge.code(locationIdentifier, chatRoomId, message.getKey()))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Returns a recent message id.\n\t *\n\t * @return the message id of a recent message. If there's nothing suitable, return 0\n\t */\n\tpublic long getRecentMessage()\n\t{\n\t\tvar now = (int) Instant.now().getEpochSecond();\n\n\t\tfor (var message : messages.entrySet())\n\t\t{\n\t\t\tif (message.getValue() + CONNECTION_CHALLENGE_MAX_TIME > now)\n\t\t\t{\n\t\t\t\treturn message.getKey();\n\t\t\t}\n\t\t}\n\t\treturn 0L;\n\t}\n\n\t/**\n\t * Removes all messages older than LIFETIME_MAX seconds.\n\t */\n\tpublic void purge()\n\t{\n\t\tvar now = (int) Instant.now().getEpochSecond();\n\t\tmessages.entrySet().removeIf(entry -> entry.getValue() + LIFETIME_MAX < now);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/RoomFlags.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.common.annotation.RsDeprecated;\n\npublic enum RoomFlags\n{\n\t/**\n\t * A room that is automatically subscribed to (joined).\n\t */\n\tAUTO_SUBSCRIBE,\n\n\t/**\n\t * Not used. Do not remove.\n\t */\n\t@RsDeprecated\n\tUNUSED,\n\n\t/**\n\t * A public chat room.\n\t */\n\tPUBLIC,\n\n\tCHALLENGE,\n\n\t/**\n\t * Signed chat room.\n\t */\n\tPGP_SIGNED\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatAvatarItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class ChatAvatarItem extends Item\n{\n\t@RsSerialized\n\tprivate byte[] imageData;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatAvatarItem()\n\t{\n\t}\n\n\tpublic ChatAvatarItem(byte[] imageData)\n\t{\n\t\tthis.imageData = imageData;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.BACKGROUND.getPriority();\n\t}\n\n\tpublic byte[] getImageData()\n\t{\n\t\treturn imageData;\n\t}\n\n\t@Override\n\tpublic ChatAvatarItem clone()\n\t{\n\t\treturn (ChatAvatarItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatAvatarItem{\" +\n\t\t\t\t\"imageData=\" + Id.toString(imageData) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatMessageItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.chat.ChatFlags;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.time.Instant;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\nimport static io.xeres.app.xrs.service.chat.ChatFlags.*;\n\npublic class ChatMessageItem extends Item\n{\n\t@RsSerialized\n\tprivate Set<ChatFlags> flags;\n\n\t@RsSerialized\n\tprivate int sendTime;\n\n\t@RsSerialized(tlvType = STR_MSG)\n\tprivate String message;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatMessageItem()\n\t{\n\t}\n\n\tpublic ChatMessageItem(String message, Set<ChatFlags> flags)\n\t{\n\t\tthis.message = message;\n\t\tsendTime = (int) Instant.now().getEpochSecond();\n\t\tthis.flags = flags;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic Set<ChatFlags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\tpublic int getSendTime()\n\t{\n\t\treturn sendTime;\n\t}\n\n\tpublic String getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic boolean isPrivate()\n\t{\n\t\treturn flags.contains(PRIVATE);\n\t}\n\n\tpublic boolean isBroadcast()\n\t{\n\t\treturn flags.contains(PUBLIC);\n\t}\n\n\tpublic boolean isPartial()\n\t{\n\t\treturn flags.contains(PARTIAL_MESSAGE);\n\t}\n\n\tpublic boolean isAvatarRequest()\n\t{\n\t\treturn flags.contains(REQUEST_AVATAR);\n\t}\n\n\t@Override\n\tpublic ChatMessageItem clone()\n\t{\n\t\treturn (ChatMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatMessageItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", sendTime=\" + sendTime +\n\t\t\t\t\", message='\" + message + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomBounce.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.Id;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.SIGNATURE;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_NAME;\n\npublic abstract class ChatRoomBounce extends Item\n{\n\tprivate long roomId;\n\tprivate long messageId;\n\tprivate String senderNickname;\n\tprivate Signature signature;\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tint writeBounceableObject(ByteBuf buf, Set<SerializationFlags> flags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += Serializer.serialize(buf, roomId);\n\t\tsize += Serializer.serialize(buf, messageId);\n\t\tsize += Serializer.serialize(buf, STR_NAME, senderNickname);\n\n\t\tif (!flags.contains(SerializationFlags.SIGNATURE))\n\t\t{\n\t\t\tsize += Serializer.serialize(buf, SIGNATURE, signature);\n\t\t}\n\t\treturn size;\n\t}\n\n\tvoid readBounceableObject(ByteBuf buf)\n\t{\n\t\troomId = Serializer.deserializeLong(buf);\n\t\tmessageId = Serializer.deserializeLong(buf);\n\t\tsenderNickname = (String) Serializer.deserialize(buf, STR_NAME);\n\t\tsignature = (Signature) Serializer.deserialize(buf, SIGNATURE);\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic void setRoomId(long roomId)\n\t{\n\t\tthis.roomId = roomId;\n\t}\n\n\tpublic long getMessageId()\n\t{\n\t\treturn messageId;\n\t}\n\n\tpublic void setMessageId(long messageId)\n\t{\n\t\tthis.messageId = messageId;\n\t}\n\n\tpublic String getSenderNickname()\n\t{\n\t\treturn senderNickname;\n\t}\n\n\tpublic void setSenderNickname(String senderNickname)\n\t{\n\t\tthis.senderNickname = senderNickname;\n\t}\n\n\tpublic Signature getSignature()\n\t{\n\t\treturn signature;\n\t}\n\n\tpublic void setSignature(Signature signature)\n\t{\n\t\tthis.signature = signature;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomBounce{\" +\n\t\t\t\t\"roomId=\" + Id.toStringLowerCase(roomId) +\n\t\t\t\t\", messageId=\" + Id.toStringLowerCase(messageId) +\n\t\t\t\t\", senderNickname='\" + senderNickname + '\\'' +\n\t\t\t\t\", signature=[something]\" +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic ChatRoomBounce clone()\n\t{\n\t\treturn (ChatRoomBounce) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomConfigItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class ChatRoomConfigItem extends Item\n{\n\t@RsSerialized\n\tprivate long roomId;\n\n\t@RsSerialized\n\tprivate int flags; // XXX: which flags?\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 21;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic int getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\t@Override\n\tpublic ChatRoomConfigItem clone()\n\t{\n\t\treturn (ChatRoomConfigItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomConfigItem{\" +\n\t\t\t\t\"roomId=\" + roomId +\n\t\t\t\t\", flags=\" + flags +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomConnectChallengeItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.crypto.hash.chat.ChatChallenge;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class ChatRoomConnectChallengeItem extends Item\n{\n\t@RsSerialized\n\tprivate long challengeCode;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomConnectChallengeItem()\n\t{\n\t}\n\n\tpublic ChatRoomConnectChallengeItem(LocationIdentifier locationIdentifier, long chatRoomId, long messageId)\n\t{\n\t\tchallengeCode = ChatChallenge.code(locationIdentifier, chatRoomId, messageId);\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 9;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic long getChallengeCode()\n\t{\n\t\treturn challengeCode;\n\t}\n\n\t@Override\n\tpublic ChatRoomConnectChallengeItem clone()\n\t{\n\t\treturn (ChatRoomConnectChallengeItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomConnectChallengeItem{\" +\n\t\t\t\t\"challengeCode=\" + Long.toUnsignedString(challengeCode) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomEvent.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport java.util.Arrays;\n\npublic enum ChatRoomEvent\n{\n\tPEER_LEFT(1),\n\tPEER_STATUS(2),\n\tPEER_JOINED(3),\n\tPEER_CHANGE_NICKNAME(4),\n\tKEEP_ALIVE(5);\n\n\tprivate final int code;\n\n\tChatRoomEvent(int code)\n\t{\n\t\tthis.code = code;\n\t}\n\n\tpublic byte getCode()\n\t{\n\t\treturn (byte) code;\n\t}\n\n\tpublic static String getFromCode(int code)\n\t{\n\t\treturn Arrays.stream(ChatRoomEvent.values())\n\t\t\t\t.filter(chatRoomEvent -> chatRoomEvent.getCode() == code)\n\t\t\t\t.findAny()\n\t\t\t\t.map(Enum::name)\n\t\t\t\t.orElse(\"\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomEventItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.time.Instant;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_NAME;\n\npublic class ChatRoomEventItem extends ChatRoomBounce implements RsSerializable\n{\n\tprivate byte eventType;\n\tprivate String status;\n\tprivate int sendTime;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomEventItem()\n\t{\n\t}\n\n\tpublic ChatRoomEventItem(ChatRoomEvent event, String status)\n\t{\n\t\teventType = event.getCode();\n\t\tthis.status = status;\n\t\tsendTime = (int) Instant.now().getEpochSecond();\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 24;\n\t}\n\n\tpublic byte getEventType()\n\t{\n\t\treturn eventType;\n\t}\n\n\tpublic String getStatus()\n\t{\n\t\treturn status;\n\t}\n\n\tpublic int getSendTime()\n\t{\n\t\treturn sendTime;\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, eventType);\n\t\tsize += serialize(buf, STR_NAME, status);\n\t\tsize += serialize(buf, sendTime);\n\n\t\tsize += writeBounceableObject(buf, serializationFlags);\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\teventType = deserializeByte(buf);\n\t\tstatus = (String) deserialize(buf, STR_NAME);\n\t\tsendTime = deserializeInt(buf);\n\n\t\treadBounceableObject(buf);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomEventItem{\" +\n\t\t\t\t\"eventType=\" + ChatRoomEvent.getFromCode(eventType) +\n\t\t\t\t\", status='\" + status + '\\'' +\n\t\t\t\t\", sendTime=\" + sendTime +\n\t\t\t\t\", super=\" + super.toString() +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic ChatRoomEventItem clone()\n\t{\n\t\treturn (ChatRoomEventItem) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomInviteItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_NAME;\nimport static io.xeres.app.xrs.service.chat.RoomFlags.*;\n\npublic class ChatRoomInviteItem extends Item\n{\n\t@RsSerialized\n\tprivate long roomId;\n\n\t@RsSerialized(tlvType = STR_NAME)\n\tprivate String roomName;\n\n\t@RsSerialized(tlvType = STR_NAME)\n\tprivate String roomTopic;\n\n\t@RsSerialized\n\tprivate Set<RoomFlags> roomFlags;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomInviteItem()\n\t{\n\t}\n\n\tpublic ChatRoomInviteItem(long roomId, String roomName, String roomTopic, Set<RoomFlags> roomFlags)\n\t{\n\t\tthis.roomId = roomId;\n\t\tthis.roomName = roomName;\n\t\tthis.roomTopic = roomTopic;\n\t\tthis.roomFlags = roomFlags;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 27;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic String getRoomName()\n\t{\n\t\treturn roomName;\n\t}\n\n\tpublic String getRoomTopic()\n\t{\n\t\treturn roomTopic;\n\t}\n\n\tpublic Set<RoomFlags> getRoomFlags()\n\t{\n\t\treturn roomFlags;\n\t}\n\n\t@SuppressWarnings(\"BooleanMethodIsAlwaysInverted\")\n\tpublic boolean isConnectionChallenge()\n\t{\n\t\treturn roomFlags.contains(CHALLENGE);\n\t}\n\n\tpublic boolean isPublic()\n\t{\n\t\treturn roomFlags.contains(PUBLIC);\n\t}\n\n\tpublic boolean isSigned()\n\t{\n\t\treturn roomFlags.contains(PGP_SIGNED);\n\t}\n\n\t@Override\n\tpublic ChatRoomInviteItem clone()\n\t{\n\t\treturn (ChatRoomInviteItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomInviteItem{\" +\n\t\t\t\t\"roomId=\" + Id.toString(roomId) +\n\t\t\t\t\", roomName='\" + roomName + '\\'' +\n\t\t\t\t\", roomTopic='\" + roomTopic + '\\'' +\n\t\t\t\t\", roomFlags=\" + roomFlags +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomInviteOldItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.common.annotation.RsDeprecated;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_NAME;\nimport static io.xeres.app.xrs.service.chat.RoomFlags.*;\n\n/**\n * Since Retroshare 0.6.5, ChatRoomInviteItem is used instead and provides the missing 'topic' parameter.\n * Note that Retroshare still sends it for compatibility reasons. We don't do it, though.\n * This class solely exists to avoid warnings in the logs.\n */\n@RsDeprecated(since = \"0.6.5\")\npublic class ChatRoomInviteOldItem extends Item\n{\n\t@RsSerialized\n\tprivate long roomId;\n\n\t@RsSerialized(tlvType = STR_NAME)\n\tprivate String roomName;\n\n\t@RsSerialized\n\tprivate Set<RoomFlags> roomFlags;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomInviteOldItem()\n\t{\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 26;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic String getRoomName()\n\t{\n\t\treturn roomName;\n\t}\n\n\tpublic Set<RoomFlags> getRoomFlags()\n\t{\n\t\treturn roomFlags;\n\t}\n\n\tpublic boolean isConnectionChallenge()\n\t{\n\t\treturn roomFlags.contains(CHALLENGE);\n\t}\n\n\tpublic boolean isPublic()\n\t{\n\t\treturn roomFlags.contains(PUBLIC);\n\t}\n\n\tpublic boolean isSigned()\n\t{\n\t\treturn roomFlags.contains(PGP_SIGNED);\n\t}\n\n\t@Override\n\tpublic ChatRoomInviteOldItem clone()\n\t{\n\t\treturn (ChatRoomInviteOldItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomInviteItem{\" +\n\t\t\t\t\"roomId=\" + Id.toString(roomId) +\n\t\t\t\t\", roomName='\" + roomName + '\\'' +\n\t\t\t\t\", roomFlags=\" + roomFlags +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomListItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class ChatRoomListItem extends Item\n{\n\t@RsSerialized\n\tprivate final List<VisibleChatRoomInfo> chatRooms = new ArrayList<>();\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomListItem()\n\t{\n\t}\n\n\tpublic ChatRoomListItem(List<VisibleChatRoomInfo> chatRooms)\n\t{\n\t\tthis.chatRooms.addAll(chatRooms);\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 25;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic List<VisibleChatRoomInfo> getChatRooms()\n\t{\n\t\treturn chatRooms;\n\t}\n\n\t@Override\n\tpublic ChatRoomListItem clone()\n\t{\n\t\treturn (ChatRoomListItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomListItem{\" +\n\t\t\t\t\"chatRooms=\" + chatRooms +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomListRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class ChatRoomListRequestItem extends Item\n{\n\t// This is an empty item\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 13;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\t@Override\n\tpublic ChatRoomListRequestItem clone()\n\t{\n\t\treturn (ChatRoomListRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomListRequestItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomMessageItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.FieldSize;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.chat.ChatFlags;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.time.Instant;\nimport java.util.EnumSet;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\nimport static io.xeres.app.xrs.service.chat.ChatFlags.LOBBY;\nimport static io.xeres.app.xrs.service.chat.ChatFlags.PRIVATE;\n\npublic class ChatRoomMessageItem extends ChatRoomBounce implements RsSerializable\n{\n\tprivate Set<ChatFlags> flags;\n\tprivate int sendTime;\n\tprivate String message;\n\tprivate long parentMessageId;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomMessageItem()\n\t{\n\t}\n\n\tpublic ChatRoomMessageItem(String message)\n\t{\n\t\tflags = EnumSet.of(LOBBY, PRIVATE);\n\t\tsendTime = (int) Instant.now().getEpochSecond();\n\t\tthis.message = message;\n\t\tparentMessageId = 0L;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 23;\n\t}\n\n\tpublic Set<ChatFlags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\tpublic int getSendTime()\n\t{\n\t\treturn sendTime;\n\t}\n\n\tpublic String getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic long getParentMessageId()\n\t{\n\t\treturn parentMessageId;\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, flags, FieldSize.INTEGER);\n\t\tsize += serialize(buf, sendTime);\n\t\tsize += serialize(buf, STR_MSG, message);\n\t\tsize += serialize(buf, parentMessageId);\n\n\t\tsize += writeBounceableObject(buf, serializationFlags);\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tflags = deserializeEnumSet(buf, ChatFlags.class, FieldSize.INTEGER);\n\t\tsendTime = deserializeInt(buf);\n\t\tmessage = (String) deserialize(buf, STR_MSG);\n\t\tparentMessageId = deserializeLong(buf);\n\n\t\treadBounceableObject(buf);\n\t}\n\n\t@Override\n\tpublic ChatRoomMessageItem clone()\n\t{\n\t\treturn (ChatRoomMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomMessageItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", sendTime=\" + sendTime +\n\t\t\t\t\", message='\" + message + '\\'' +\n\t\t\t\t\", parentMessageId=\" + parentMessageId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatRoomUnsubscribeItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class ChatRoomUnsubscribeItem extends Item\n{\n\t@RsSerialized\n\tprivate long roomId;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatRoomUnsubscribeItem()\n\t{\n\t}\n\n\tpublic ChatRoomUnsubscribeItem(long roomId)\n\t{\n\t\tthis.roomId = roomId;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 10;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\t@Override\n\tpublic ChatRoomUnsubscribeItem clone()\n\t{\n\t\treturn (ChatRoomUnsubscribeItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomUnsubscribeItem{\" +\n\t\t\t\t\"roomId=\" + roomId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/ChatStatusItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.chat.ChatFlags;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\n\npublic class ChatStatusItem extends Item\n{\n\t@RsSerialized\n\tprivate Set<ChatFlags> flags;\n\n\t@RsSerialized(tlvType = STR_MSG)\n\tprivate String status;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ChatStatusItem()\n\t{\n\t}\n\n\tpublic ChatStatusItem(String status, Set<ChatFlags> flags)\n\t{\n\t\tthis.status = status;\n\t\tthis.flags = flags;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 4;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.BACKGROUND.getPriority();\n\t}\n\n\tpublic Set<ChatFlags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\tpublic String getStatus()\n\t{\n\t\treturn status;\n\t}\n\n\t@Override\n\tpublic ChatStatusItem clone()\n\t{\n\t\treturn (ChatStatusItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatStatusItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", status='\" + status + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/PrivateChatMessageConfigItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\n\npublic class PrivateChatMessageConfigItem extends Item\n{\n\t@RsSerialized\n\tprivate LocationIdentifier locationIdentifier;\n\n\t@RsSerialized\n\tprivate int chatFlags; // XXX: enumsets\n\n\t@RsSerialized\n\tprivate int configFlags; // XXX: use an enumSet\n\n\t@RsSerialized\n\tprivate int sendTime;\n\n\t@RsSerialized(tlvType = STR_MSG)\n\tprivate String message;\n\n\t@RsSerialized\n\tint receiveTime;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 5;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic LocationIdentifier getLocationId()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic int getChatFlags()\n\t{\n\t\treturn chatFlags;\n\t}\n\n\tpublic int getConfigFlags()\n\t{\n\t\treturn configFlags;\n\t}\n\n\tpublic int getSendTime()\n\t{\n\t\treturn sendTime;\n\t}\n\n\tpublic String getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic int getReceiveTime()\n\t{\n\t\treturn receiveTime;\n\t}\n\n\t@Override\n\tpublic PrivateChatMessageConfigItem clone()\n\t{\n\t\treturn (PrivateChatMessageConfigItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"PrivateChatMessageConfigItem{\" +\n\t\t\t\t\"locationIdentifier=\" + locationIdentifier +\n\t\t\t\t\", chatFlags=\" + chatFlags +\n\t\t\t\t\", configFlags=\" + configFlags +\n\t\t\t\t\", sendTime=\" + sendTime +\n\t\t\t\t\", message='\" + message + '\\'' +\n\t\t\t\t\", receiveTime=\" + receiveTime +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/PrivateOutgoingMapItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.Map;\n\npublic class PrivateOutgoingMapItem extends Item\n{\n\t@RsSerialized\n\tprivate Map<Long, ChatMessageItem> store;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 28;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\tpublic Map<Long, ChatMessageItem> getStore()\n\t{\n\t\treturn store;\n\t}\n\n\t@Override\n\tpublic PrivateOutgoingMapItem clone()\n\t{\n\t\treturn (PrivateOutgoingMapItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"PrivateOutgoingMapItem{\" +\n\t\t\t\t\"store=\" + store +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/SubscribedChatRoomConfigItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.Map;\nimport java.util.Set;\n\npublic class SubscribedChatRoomConfigItem extends Item\n{\n\t@RsSerialized\n\tprivate long roomId;\n\n\t@RsSerialized\n\tprivate String roomName;\n\n\t@RsSerialized\n\tprivate String roomTopic;\n\n\t@RsSerialized\n\tprivate Set<LocationIdentifier> participatingLocations; // XXX: do we serialize Sets yet? no, see #19\n\n\t@RsSerialized\n\tprivate GxsId gxsId;\n\n\t@RsSerialized\n\tprivate Set<RoomFlags> flags;\n\n\t@RsSerialized\n\tprivate Map<GxsId, Long> gxsIds;\n\n\t@RsSerialized\n\tprivate long lastActivity;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.CHAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 29;\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic String getRoomName()\n\t{\n\t\treturn roomName;\n\t}\n\n\tpublic String getRoomTopic()\n\t{\n\t\treturn roomTopic;\n\t}\n\n\tpublic Set<LocationIdentifier> getParticipatingLocations()\n\t{\n\t\treturn participatingLocations;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic Set<RoomFlags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\tpublic Map<GxsId, Long> getGxsIds()\n\t{\n\t\treturn gxsIds;\n\t}\n\n\tpublic long getLastActivity()\n\t{\n\t\treturn lastActivity;\n\t}\n\n\t@Override\n\tpublic SubscribedChatRoomConfigItem clone()\n\t{\n\t\treturn (SubscribedChatRoomConfigItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"SubscribedChatRoomConfigItem{\" +\n\t\t\t\t\"roomId=\" + roomId +\n\t\t\t\t\", roomName='\" + roomName + '\\'' +\n\t\t\t\t\", roomTopic='\" + roomTopic + '\\'' +\n\t\t\t\t\", participatingLocations=\" + participatingLocations +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", flags=\" + flags +\n\t\t\t\t\", gxsIds=\" + gxsIds +\n\t\t\t\t\", lastActivity=\" + lastActivity +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/chat/item/VisibleChatRoomInfo.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.common.id.Id;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_NAME;\n\npublic class VisibleChatRoomInfo\n{\n\t@RsSerialized\n\tprivate long id;\n\n\t@RsSerialized(tlvType = STR_NAME)\n\tprivate String name;\n\n\t@RsSerialized(tlvType = STR_NAME)\n\tprivate String topic;\n\n\t@RsSerialized\n\tprivate int count;\n\n\t@RsSerialized\n\tprivate Set<RoomFlags> flags;\n\n\tpublic VisibleChatRoomInfo()\n\t{\n\t\t// Required\n\t}\n\n\tpublic VisibleChatRoomInfo(long id, String name, String topic, int count, Set<RoomFlags> roomFlags)\n\t{\n\t\tthis.id = id;\n\t\tthis.name = name;\n\t\tthis.topic = topic;\n\t\tthis.count = count;\n\t\tflags = roomFlags;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic String getTopic()\n\t{\n\t\treturn topic;\n\t}\n\n\tpublic int getCount()\n\t{\n\t\treturn count;\n\t}\n\n\tpublic Set<RoomFlags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VisibleChatRoomInfo{\" +\n\t\t\t\t\"id=\" + Id.toStringLowerCase(id) +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t\", topic='\" + topic + '\\'' +\n\t\t\t\t\", count=\" + count +\n\t\t\t\t\", flags=\" + flags +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/discovery/DiscoveryRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.discovery.item.DiscoveryContactItem;\nimport io.xeres.app.xrs.service.discovery.item.DiscoveryIdentityListItem;\nimport io.xeres.app.xrs.service.discovery.item.DiscoveryPgpKeyItem;\nimport io.xeres.app.xrs.service.discovery.item.DiscoveryPgpListItem;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.bouncycastle.openpgp.PGPPublicKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.info.BuildProperties;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.io.IOException;\nimport java.net.InetSocketAddress;\nimport java.net.SocketAddress;\nimport java.security.InvalidKeyException;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.app.net.util.NetworkMode.getNetworkMode;\nimport static io.xeres.common.protocol.xrs.RsServiceType.DISCOVERY;\nimport static java.util.function.Predicate.not;\nimport static java.util.stream.Collectors.toSet;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Component\npublic class DiscoveryRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DiscoveryRsService.class);\n\n\tprivate final ProfileService profileService;\n\tprivate final LocationService locationService;\n\tprivate final IdentityService identityService;\n\tprivate final BuildProperties buildProperties;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final IdentityManager identityManager;\n\tprivate final StatusNotificationService statusNotificationService;\n\n\tpublic DiscoveryRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, ProfileService profileService, LocationService locationService, IdentityService identityService, BuildProperties buildProperties, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, StatusNotificationService statusNotificationService)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.profileService = profileService;\n\t\tthis.locationService = locationService;\n\t\tthis.identityService = identityService;\n\t\tthis.identityManager = identityManager;\n\t\tthis.buildProperties = buildProperties;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.statusNotificationService = statusNotificationService;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn DISCOVERY;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.NORMAL;\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.schedule(\n\t\t\t\t() -> sendOwnContactAndIdentities(peerConnection)\n\t\t\t\t, 0,\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\tprivate void sendOwnContactAndIdentities(PeerConnection peerConnection)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar ownLocation = locationService.findOwnLocation().orElseThrow();\n\t\t\tsendContact(peerConnection, ownLocation);\n\t\t\tsendIdentity(peerConnection, identityService.getOwnIdentity()); // XXX: in the future we will have several identities, just get the signed ones here\n\t\t\t// XXX: also send our own other locations, if any (ie. laptop, etc...). XXX: this should be already done in the current code but check. it is done when the peer sends us his list of friends and has us in it\n\t\t}\n\t}\n\n\tprivate void sendContact(Location toLocation, Location aboutLocation)\n\t{\n\t\tsendContact(toLocation, aboutLocation, null);\n\t}\n\n\tprivate void sendContact(PeerConnection peerConnection, Location aboutLocation)\n\t{\n\t\tsendContact(peerConnection.getLocation(), aboutLocation, peerConnection.getCtx().channel().remoteAddress());\n\t}\n\n\tprivate void sendContact(Location toLocation, Location aboutLocation, SocketAddress toLocationAddress)\n\t{\n\t\tlog.debug(\"Sending contact information of {} to {}\", aboutLocation, toLocation);\n\n\t\tvar builder = DiscoveryContactItem.builder();\n\n\t\tbuilder.setPgpIdentifier(aboutLocation.getProfile().getPgpIdentifier());\n\t\tbuilder.setLocationIdentifier(aboutLocation.getLocationIdentifier());\n\t\tbuilder.setLocationName(aboutLocation.getSafeName());\n\t\tif (aboutLocation.isOwn())\n\t\t{\n\t\t\tbuilder.setVersion(buildProperties.getName() + \" \" + buildProperties.getVersion());\n\t\t}\n\t\tbuilder.setNetMode(aboutLocation.getNetMode());\n\t\tbuilder.setVsDisc(aboutLocation.isDiscoverable() ? 2 : 0);\n\t\tbuilder.setVsDht(aboutLocation.isDht() ? 2 : 0);\n\t\tbuilder.setLastContact((int) (aboutLocation.getLastConnected() != null ? aboutLocation.getLastConnected().getEpochSecond() : Instant.now().getEpochSecond())); // RS uses Instant.now() XXX: find out if there is any issue with that change. it tells since how long we've been connected\n\t\taboutLocation.getConnections().stream()\n\t\t\t\t.filter(connection -> connection.getType() == PeerAddress.Type.IPV4)\n\t\t\t\t.filter(not(Connection::isExternal))\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(connection -> builder.setLocalAddressV4(PeerAddress.fromAddress(connection.getAddress())));\n\t\taboutLocation.getConnections().stream()\n\t\t\t\t.filter(connection -> connection.getType() == PeerAddress.Type.IPV4)\n\t\t\t\t.filter(Connection::isExternal)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(connection -> builder.setExternalAddressV4(PeerAddress.fromAddress(connection.getAddress())));\n\t\tif (aboutLocation.equals(toLocation) && toLocationAddress != null)\n\t\t{\n\t\t\t// Tell the peer about how we see its IP address\n\t\t\tbuilder.setCurrentConnectAddress(PeerAddress.fromSocketAddress(toLocationAddress));\n\t\t}\n\t\taboutLocation.getConnections().stream()\n\t\t\t\t.filter(connection -> connection.getType() == PeerAddress.Type.HOSTNAME)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(connection -> builder.setHostname(connection.getHostname()));\n\t\tpeerConnectionManager.writeItem(toLocation, builder.build(), this);\n\t}\n\n\tprivate void sendIdentity(PeerConnection peerConnection, IdentityGroupItem identityGroupItem)\n\t{\n\t\tlog.debug(\"Sending our own identity {} to {}\", identityGroupItem, peerConnection);\n\n\t\tpeerConnectionManager.writeItem(peerConnection, new DiscoveryIdentityListItem(List.of(identityGroupItem.getGxsId())), this);\n\t}\n\n\tprivate void askForPgpKeys(PeerConnection peerConnection, Set<Long> pgpIds)\n\t{\n\t\tvar pgpListItem = new DiscoveryPgpListItem(DiscoveryPgpListItem.Mode.GET_CERT, pgpIds);\n\t\tpeerConnectionManager.writeItem(peerConnection, pgpListItem, this);\n\t}\n\n\tprivate void sendOwnContacts(PeerConnection peerConnection)\n\t{\n\t\tif (!locationService.findOwnLocation().orElseThrow().isDiscoverable())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar pgpIds = profileService.getAllDiscoverableProfiles().stream()\n\t\t\t\t.map(Profile::getPgpIdentifier)\n\t\t\t\t.collect(toSet());\n\n\t\tlog.debug(\"Sending list of friends...\");\n\t\tassert !pgpIds.isEmpty();\n\t\tpeerConnectionManager.writeItem(peerConnection, new DiscoveryPgpListItem(DiscoveryPgpListItem.Mode.FRIENDS, pgpIds), this);\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof DiscoveryContactItem discoveryContactItem)\n\t\t{\n\t\t\thandleContact(sender, discoveryContactItem);\n\t\t}\n\t\telse if (item instanceof DiscoveryIdentityListItem discoveryIdentityListItem)\n\t\t{\n\t\t\thandleIdentityList(sender, discoveryIdentityListItem);\n\t\t}\n\t\telse if (item instanceof DiscoveryPgpListItem discoveryPgpListItem)\n\t\t{\n\t\t\thandlePgpList(sender, discoveryPgpListItem);\n\t\t}\n\t\telse if (item instanceof DiscoveryPgpKeyItem discoveryPgpKeyItem)\n\t\t{\n\t\t\thandlePgpKey(sender, discoveryPgpKeyItem);\n\t\t}\n\t}\n\n\tprivate void handleContact(PeerConnection peerConnection, DiscoveryContactItem discoveryContactItem)\n\t{\n\t\tvar peerLocation = peerConnection.getLocation();\n\t\tvar existingContactLocation = locationService.findLocationByLocationIdentifier(discoveryContactItem.getLocationIdentifier());\n\n\t\texistingContactLocation.ifPresentOrElse(contactLocation -> {\n\t\t\tif (contactLocation.equals(peerLocation))\n\t\t\t{\n\t\t\t\t// Contact information of the peer\n\t\t\t\tupdateConnectedContact(peerConnection, discoveryContactItem, peerLocation, contactLocation);\n\t\t\t}\n\t\t\telse if (contactLocation.equals(locationService.findOwnLocation().orElseThrow()))\n\t\t\t{\n\t\t\t\t// Contact information about ourselves (this can be used to help us find our external IP address\n\t\t\t\tupdateOwnContactLocation(discoveryContactItem);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// Contact information about our friends\n\t\t\t\tupdateCommonContactLocation(peerConnection, discoveryContactItem, contactLocation);\n\t\t\t}\n\t\t}, () -> addNewContactLocation(discoveryContactItem));\n\t}\n\n\tprivate void updateConnectedContact(PeerConnection peerConnection, DiscoveryContactItem discoveryContactItem, Location peerLocation, Location contactLocation)\n\t{\n\t\tlog.debug(\"Peer is sending its own location: {}\", discoveryContactItem);\n\t\tif (discoveryContactItem.getPgpIdentifier() != contactLocation.getProfile().getPgpIdentifier())\n\t\t{\n\t\t\tlog.error(\"PGP identifier or peer doesn't match the key we have about him. Ignoring.\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar updatedLocation = updateLocation(peerLocation, discoveryContactItem);\n\t\tpeerConnection.updateLocation(updatedLocation);\n\n\t\tif (peerLocation.getProfile().isPartial())\n\t\t{\n\t\t\t// Ask for its PGP public key\n\t\t\tlog.debug(\"Asking for PGP public key of peer\");\n\t\t\taskForPgpKeys(peerConnection, Set.of(peerLocation.getProfile().getPgpIdentifier()));\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Send our friends\n\t\t\tsendOwnContacts(peerConnection);\n\t\t}\n\t}\n\n\tprivate static void updateOwnContactLocation(DiscoveryContactItem discoveryContactItem)\n\t{\n\t\tlog.debug(\"Peer is sending our own location: {}\", discoveryContactItem);\n\t\t// XXX: process the IP in case we don't find our external address and it could help\n\t\t// XXX: beware! RS seems to send ipv4 address in the ipv6 structure...\n\t\t// XXX: comments also seem to suggest this can be used to check if the connected IP is the same as our external IP (currentConnectedAddress is null/invalid, though (maybe ipv6? grmbl))\n\t}\n\n\tprivate void updateCommonContactLocation(PeerConnection peerConnection, DiscoveryContactItem discoveryContactItem, Location contactLocation)\n\t{\n\t\tif (contactLocation.getProfile().isAccepted())\n\t\t{\n\t\t\tlog.debug(\"Would update friend here\");\n\t\t\tvar updatedLocation = updateLocation(contactLocation, discoveryContactItem);\n\t\t\tpeerConnection.updateLocation(updatedLocation);\n\t\t}\n\t}\n\n\tprivate void addNewContactLocation(DiscoveryContactItem discoveryContactItem)\n\t{\n\t\tlog.debug(\"New location\");\n\n\t\tprofileService.findProfileByPgpIdentifier(discoveryContactItem.getPgpIdentifier())\n\t\t\t\t.ifPresentOrElse(profile -> {\n\t\t\t\t\tif (profile.isAccepted())\n\t\t\t\t\t{\n\t\t\t\t\t\t// New location of a friend\n\t\t\t\t\t\tvar newLocation = Location.createLocation(discoveryContactItem.getLocationName(), profile, discoveryContactItem.getLocationIdentifier());\n\t\t\t\t\t\tnewLocation = updateLocation(newLocation, discoveryContactItem);\n\t\t\t\t\t\tlog.debug(\"New location of a friend, added: {}\", newLocation);\n\t\t\t\t\t\tstatusNotificationService.setTotalUsers((int) locationService.countLocations());\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\t// Friend of friend, but shouldn't happen because RS only sends common contacts.\n\t\t\t\t\t\tlog.debug(\"New location for profile {} that we have but is not a friend, ignoring...\", profile);\n\t\t\t\t\t}\n\t\t\t\t}, () -> {\n\t\t\t\t\t// Friend of friend, but shouldn't happen because RS only sends common contacts.\n\t\t\t\t\t// We don't have any use for those. RS uses them as potential proxies/relays for the DHT, but I have\n\t\t\t\t\t// yet to see this in the wild because it shouldn't happen.\n\t\t\t\t\tlog.debug(\"New location for friend of friend {}, ignoring...\", log.isDebugEnabled() ? Id.toString(discoveryContactItem.getPgpIdentifier()) : \"\");\n\t\t\t\t});\n\t}\n\n\tprivate Location updateLocation(Location location, DiscoveryContactItem discoveryContactItem)\n\t{\n\t\tvar addresses = new ArrayList<PeerAddress>();\n\t\tif (discoveryContactItem.getExternalAddressV4() != null)\n\t\t{\n\t\t\taddresses.add(discoveryContactItem.getExternalAddressV4());\n\n\t\t\t// If we have a hostname, use the port from the external address\n\t\t\tif (isNotBlank(discoveryContactItem.getHostname()))\n\t\t\t{\n\t\t\t\taddresses.add(PeerAddress.fromHostname(discoveryContactItem.getHostname(), ((InetSocketAddress) discoveryContactItem.getExternalAddressV4().getSocketAddress()).getPort()));\n\t\t\t}\n\t\t}\n\t\tif (discoveryContactItem.getLocalAddressV4() != null)\n\t\t{\n\t\t\taddresses.add(discoveryContactItem.getLocalAddressV4());\n\t\t}\n\t\taddresses.addAll(discoveryContactItem.getExternalAddressList());\n\n\t\treturn locationService.update(\n\t\t\t\tlocation,\n\t\t\t\tdiscoveryContactItem.getLocationName(),\n\t\t\t\tdiscoveryContactItem.getNetMode(),\n\t\t\t\tdiscoveryContactItem.getVersion(),\n\t\t\t\tgetNetworkMode(discoveryContactItem.getVsDisc(), discoveryContactItem.getVsDht()),\n\t\t\t\taddresses);\n\t}\n\n\tprivate void handlePgpList(PeerConnection peerConnection, DiscoveryPgpListItem discoveryPgpListItem)\n\t{\n\t\tvar ownLocation = locationService.findOwnLocation().orElseThrow();\n\t\tif (!ownLocation.isDiscoverable())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (discoveryPgpListItem.getMode() == DiscoveryPgpListItem.Mode.GET_CERT)\n\t\t{\n\t\t\tvar friends = getMutualFriends(discoveryPgpListItem.getPgpIds());\n\n\t\t\tfriends.forEach(profile -> peerConnectionManager.writeItem(peerConnection, new DiscoveryPgpKeyItem(profile.getPgpIdentifier(), profile.getPgpPublicKeyData()), this)); // XXX: RS does that slowly it seems... about one key every few seconds\n\t\t}\n\t\telse if (discoveryPgpListItem.getMode() == DiscoveryPgpListItem.Mode.FRIENDS)\n\t\t{\n\t\t\t// The peer sent us his list of friends.\n\t\t\tlog.debug(\"Received peer's list of friends: {}\", discoveryPgpListItem);\n\n\t\t\t// Only ask for the ones we don't already have, including partial profiles\n\t\t\tvar pgpIds = new HashSet<>(discoveryPgpListItem.getPgpIds());\n\t\t\tprofileService.findAllCompleteProfilesByPgpIdentifiers(pgpIds).stream()\n\t\t\t\t\t.map(Profile::getPgpIdentifier)\n\t\t\t\t\t.forEach(pgpIds::remove);\n\n\t\t\tif (!pgpIds.isEmpty())\n\t\t\t{\n\t\t\t\taskForPgpKeys(peerConnection, pgpIds);\n\t\t\t}\n\n\t\t\t// Send contact info of all mutual friends with discovery enabled to peer,\n\t\t\t// including the peer itself if it wants to and also our other locations.\n\t\t\tvar mutualFriends = getMutualFriends(discoveryPgpListItem.getPgpIds());\n\t\t\tvar locationsToSend = mutualFriends.stream()\n\t\t\t\t\t.map(Profile::getLocations)\n\t\t\t\t\t.flatMap(List::stream)\n\t\t\t\t\t.filter(location -> !location.equals(ownLocation)) // own location was sent at beginning\n\t\t\t\t\t.filter(location -> location.getName() != null) // Do not send locations that have no name (they have been automatically added using the profile)\n\t\t\t\t\t.toList();\n\n\t\t\tlocationsToSend.forEach(location -> sendContact(peerConnection, location));\n\n\t\t\t// Inform all our online mutual friends about peer (except itself as we just sent it above).\n\t\t\tlocationsToSend.stream()\n\t\t\t\t\t.filter(location -> !location.equals(peerConnection.getLocation()) && location.isConnected())\n\t\t\t\t\t.forEach(location -> sendContact(location, peerConnection.getLocation()));\n\t\t}\n\t}\n\n\tprivate List<Profile> getMutualFriends(Set<Long> pgpIds)\n\t{\n\t\treturn profileService.findAllDiscoverableProfilesByPgpIdentifiers(pgpIds);\n\t}\n\n\tprivate void handlePgpKey(PeerConnection peerConnection, DiscoveryPgpKeyItem discoveryPgpKeyItem)\n\t{\n\t\ttry\n\t\t{\n\t\t\tlog.debug(\"Got PGP key for ID {}\", log.isDebugEnabled() ? Id.toString(discoveryPgpKeyItem.getPgpIdentifier()) : \"\");\n\n\t\t\tvar pgpPublicKey = PGP.getPGPPublicKey(discoveryPgpKeyItem.getKeyData());\n\n\t\t\tif (discoveryPgpKeyItem.getPgpIdentifier() != pgpPublicKey.getKeyID())\n\t\t\t{\n\t\t\t\tlog.warn(\"PGP key from {} has an ID ({}) which doesn't match the advertised ID {}\", peerConnection.getLocation(), pgpPublicKey.getKeyID(), discoveryPgpKeyItem.getPgpIdentifier());\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tvar profileFingerprint = new ProfileFingerprint(pgpPublicKey.getFingerprint());\n\t\t\tprofileService.findProfileByPgpFingerprint(profileFingerprint)\n\t\t\t\t\t.ifPresentOrElse(profile -> {\n\t\t\t\t\t\t//noinspection StatementWithEmptyBody\n\t\t\t\t\t\tif (profile.isPartial())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// The PGP key is about a partial profile, thoroughly check if the peer is the partial profile itself\n\t\t\t\t\t\t\tif (discoveryPgpKeyItem.getPgpIdentifier() == peerConnection.getLocation().getProfile().getPgpIdentifier() // Incoming key PGP id is the one of the remote peer\n\t\t\t\t\t\t\t\t\t&& profile.getPgpIdentifier() == peerConnection.getLocation().getProfile().getPgpIdentifier() // ShortInvite PGP ID matches remote peer\n\t\t\t\t\t\t\t\t\t&& profileFingerprint.equals(peerConnection.getLocation().getProfile().getProfileFingerprint())) // Incoming key fingerprint matches remote peer\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// We can save its PGP key and promote it to full profile.\n\t\t\t\t\t\t\t\tprofile.setPgpPublicKeyData(discoveryPgpKeyItem.getKeyData());\n\t\t\t\t\t\t\t\tprofile.setCreated(pgpPublicKey.getCreationTime().toInstant());\n\t\t\t\t\t\t\t\tprofileService.createOrUpdateProfile(profile);\n\n\t\t\t\t\t\t\t\tsendOwnContacts(peerConnection);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// XXX: check the key and complain if it doesn't match\n\t\t\t\t\t\t}\n\t\t\t\t\t}, () -> {\n\t\t\t\t\t\t// Create a new profile and save the key\n\t\t\t\t\t\tlog.debug(\"Creating new profile for id {}\", log.isDebugEnabled() ? Id.toString(discoveryPgpKeyItem.getPgpIdentifier()) : \"\");\n\t\t\t\t\t\tvar newProfile = createNewProfile(pgpPublicKey);\n\t\t\t\t\t\tprofileService.createOrUpdateProfile(newProfile);\n\t\t\t\t\t});\n\t\t}\n\t\tcatch (InvalidKeyException _)\n\t\t{\n\t\t\tlog.warn(\"Invalid PGP public key for profile id {}\", Id.toString(discoveryPgpKeyItem.getPgpIdentifier()));\n\t\t}\n\t}\n\n\tprivate static Profile createNewProfile(PGPPublicKey pgpPublicKey)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Profile.createProfile(pgpPublicKey.getUserIDs().next(), pgpPublicKey.getKeyID(), pgpPublicKey.getCreationTime().toInstant(), new ProfileFingerprint(pgpPublicKey.getFingerprint()), pgpPublicKey.getEncoded());\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Error while reading PGP public key for PGP id {}\" + pgpPublicKey.getUserIDs().next() + \": {}\" + e.getMessage());\n\t\t}\n\t}\n\n\tprivate void handleIdentityList(PeerConnection peerConnection, DiscoveryIdentityListItem discoveryIdentityListItem)\n\t{\n\t\tlog.debug(\"Got identities from friend: {}, requesting...\", discoveryIdentityListItem);\n\t\tvar friends = new HashSet<>(discoveryIdentityListItem.getIdentities());\n\n\t\tidentityManager.setAsFriend(friends);\n\t\tidentityManager.fetchGxsGroups(peerConnection, friends);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryContactItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.FieldSize;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.NetMode;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.ArrayList;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\npublic class DiscoveryContactItem extends Item implements RsSerializable\n{\n\tprivate long pgpIdentifier;\n\tprivate LocationIdentifier locationIdentifier;\n\tprivate String locationName;\n\tprivate String version;\n\tprivate Set<NetMode> netMode; // 1: UDP, 2: UPNP, 3: EXT, 4: HIDDEN, 5: UNREACHABLE\n\tprivate short vsDisc; // 0: off, 1: minimal (never implemented I think), 2: full\n\tprivate short vsDht; // 0: off, 1: passive (never implemented too?!), 2: full\n\tprivate int lastContact;\n\n\tprivate String hiddenAddress;\n\tprivate short hiddenPort;\n\n\tprivate PeerAddress localAddressV4;\n\tprivate PeerAddress externalAddressV4;\n\tprivate PeerAddress localAddressV6;\n\tprivate PeerAddress externalAddressV6;\n\tprivate PeerAddress currentConnectAddress;\n\tprivate String hostname;\n\tprivate List<PeerAddress> localAddressList = new ArrayList<>();\n\tprivate List<PeerAddress> externalAddressList = new ArrayList<>();\n\n\t@SuppressWarnings(\"unused\")\n\tpublic DiscoveryContactItem()\n\t{\n\t}\n\n\tprivate DiscoveryContactItem(Builder builder)\n\t{\n\t\tpgpIdentifier = builder.pgpIdentifier;\n\t\tlocationIdentifier = builder.locationIdentifier;\n\t\tlocationName = builder.location;\n\t\tversion = builder.version;\n\t\tnetMode = EnumSet.of(builder.netMode);\n\t\tvsDisc = builder.vsDisc;\n\t\tvsDht = builder.vsDht;\n\t\tlastContact = builder.lastContact;\n\t\thiddenAddress = builder.hiddenAddress;\n\t\thiddenPort = builder.hiddenPort;\n\t\tlocalAddressV4 = builder.localAddressV4;\n\t\texternalAddressV4 = builder.externalAddressV4;\n\t\tlocalAddressV6 = builder.localAddressV6;\n\t\texternalAddressV6 = builder.externalAddressV6;\n\t\tcurrentConnectAddress = builder.currentConnectAddress;\n\t\thostname = builder.hostname;\n\t\tif (builder.localAddressList != null)\n\t\t{\n\t\t\tlocalAddressList = builder.localAddressList;\n\t\t}\n\t\tif (builder.externalAddressList != null)\n\t\t{\n\t\t\texternalAddressList = builder.externalAddressList;\n\t\t}\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.DISCOVERY.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 5;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.BACKGROUND.getPriority();\n\t}\n\n\tpublic static Builder builder()\n\t{\n\t\treturn new Builder();\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, pgpIdentifier);\n\t\tsize += serialize(buf, locationIdentifier, LocationIdentifier.class);\n\t\tsize += serialize(buf, STR_LOCATION, locationName);\n\t\tsize += serialize(buf, STR_VERSION, version);\n\t\tsize += serialize(buf, netMode, FieldSize.INTEGER);\n\t\tsize += serialize(buf, vsDisc);\n\t\tsize += serialize(buf, vsDht);\n\t\tsize += serialize(buf, lastContact);\n\n\t\tif (hiddenAddress != null)\n\t\t{\n\t\t\tsize += serialize(buf, STR_DOM_ADDR, hiddenAddress);\n\t\t\tsize += serialize(buf, hiddenPort);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsize += serialize(buf, ADDRESS, localAddressV4);\n\t\t\tsize += serialize(buf, ADDRESS, externalAddressV4);\n\t\t\tsize += serialize(buf, ADDRESS, localAddressV6);\n\t\t\tsize += serialize(buf, ADDRESS, externalAddressV6);\n\t\t\tsize += serialize(buf, ADDRESS, currentConnectAddress);\n\t\t\tsize += serialize(buf, STR_DYNDNS, hostname);\n\n\t\t\tsize += serialize(buf, ADDRESS_SET, localAddressList);\n\t\t\tsize += serialize(buf, ADDRESS_SET, externalAddressList);\n\t\t}\n\t\treturn size;\n\t}\n\n\t@Override\n\t@SuppressWarnings(\"unchecked\")\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tpgpIdentifier = deserializeLong(buf);\n\t\tlocationIdentifier = (LocationIdentifier) deserializeIdentifier(buf, LocationIdentifier.class);\n\t\tlocationName = (String) deserialize(buf, STR_LOCATION);\n\t\tversion = (String) deserialize(buf, STR_VERSION);\n\t\tnetMode = deserializeEnumSet(buf, NetMode.class, FieldSize.INTEGER);\n\t\tvsDisc = deserializeShort(buf);\n\t\tvsDht = deserializeShort(buf);\n\t\tlastContact = deserializeInt(buf);\n\n\t\tif (buf.getUnsignedShort(buf.readerIndex()) == STR_DOM_ADDR.getValue()) // RS uses a hack to parse hidden addresses, so we do the same :/\n\t\t{\n\t\t\t// is hidden address\n\t\t\thiddenAddress = (String) deserialize(buf, STR_DOM_ADDR);\n\t\t\thiddenPort = deserializeShort(buf);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// is normal address\n\t\t\tlocalAddressV4 = (PeerAddress) deserialize(buf, ADDRESS);\n\t\t\texternalAddressV4 = (PeerAddress) deserialize(buf, ADDRESS);\n\t\t\tlocalAddressV6 = (PeerAddress) deserialize(buf, ADDRESS);\n\t\t\texternalAddressV6 = (PeerAddress) deserialize(buf, ADDRESS);\n\t\t\tcurrentConnectAddress = (PeerAddress) deserialize(buf, ADDRESS);\n\t\t\thostname = (String) deserialize(buf, STR_DYNDNS);\n\n\t\t\tlocalAddressList = (List<PeerAddress>) deserialize(buf, ADDRESS_SET);\n\t\t\texternalAddressList = (List<PeerAddress>) deserialize(buf, ADDRESS_SET);\n\t\t}\n\t}\n\n\tpublic long getPgpIdentifier()\n\t{\n\t\treturn pgpIdentifier;\n\t}\n\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic String getLocationName()\n\t{\n\t\treturn locationName;\n\t}\n\n\tpublic String getVersion()\n\t{\n\t\treturn version;\n\t}\n\n\tpublic NetMode getNetMode()\n\t{\n\t\t// TODO: find if there's a better way to handle that netmode... RS used a flag even though it really should be a value...\n\t\tif (netMode.contains(NetMode.HIDDEN))\n\t\t{\n\t\t\treturn NetMode.HIDDEN;\n\t\t}\n\t\telse if (netMode.contains(NetMode.EXT))\n\t\t{\n\t\t\treturn NetMode.EXT;\n\t\t}\n\t\telse if (netMode.contains(NetMode.UPNP))\n\t\t{\n\t\t\treturn NetMode.UPNP;\n\t\t}\n\t\telse if (netMode.contains(NetMode.UDP))\n\t\t{\n\t\t\treturn NetMode.UDP;\n\t\t}\n\t\telse if (netMode.contains(NetMode.UNREACHABLE))\n\t\t{\n\t\t\treturn NetMode.UNREACHABLE;\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn NetMode.UNKNOWN;\n\t\t}\n\t}\n\n\tpublic short getVsDisc()\n\t{\n\t\treturn vsDisc;\n\t}\n\n\tpublic short getVsDht()\n\t{\n\t\treturn vsDht;\n\t}\n\n\tpublic int getLastContact()\n\t{\n\t\treturn lastContact;\n\t}\n\n\tpublic String getHiddenAddress()\n\t{\n\t\treturn hiddenAddress;\n\t}\n\n\tpublic short getHiddenPort()\n\t{\n\t\treturn hiddenPort;\n\t}\n\n\tpublic PeerAddress getLocalAddressV4()\n\t{\n\t\treturn localAddressV4;\n\t}\n\n\tpublic PeerAddress getExternalAddressV4()\n\t{\n\t\treturn externalAddressV4;\n\t}\n\n\tpublic PeerAddress getLocalAddressV6()\n\t{\n\t\treturn localAddressV6;\n\t}\n\n\tpublic PeerAddress getExternalAddressV6()\n\t{\n\t\treturn externalAddressV6;\n\t}\n\n\tpublic PeerAddress getCurrentConnectAddress()\n\t{\n\t\treturn currentConnectAddress;\n\t}\n\n\tpublic String getHostname()\n\t{\n\t\treturn hostname;\n\t}\n\n\tpublic List<PeerAddress> getLocalAddressList()\n\t{\n\t\treturn localAddressList;\n\t}\n\n\tpublic List<PeerAddress> getExternalAddressList()\n\t{\n\t\treturn externalAddressList;\n\t}\n\n\t@Override\n\tpublic DiscoveryContactItem clone()\n\t{\n\t\treturn (DiscoveryContactItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"DiscoveryContactItem{\" +\n\t\t\t\t\"pgpIdentifier=\" + Id.toString(pgpIdentifier) +\n\t\t\t\t\", locationIdentifier=\" + locationIdentifier +\n\t\t\t\t\", location='\" + locationName + '\\'' +\n\t\t\t\t\", version='\" + version + '\\'' +\n\t\t\t\t\", netMode=\" + netMode +\n\t\t\t\t\", vsDisc=\" + vsDisc +\n\t\t\t\t\", vsDht=\" + vsDht +\n\t\t\t\t\", lastContact=\" + lastContact +\n\t\t\t\t\", hiddenAddress='\" + hiddenAddress + '\\'' +\n\t\t\t\t\", hiddenPort=\" + hiddenPort +\n\t\t\t\t\", localAddressV4=\" + localAddressV4 +\n\t\t\t\t\", externalAddressV4=\" + externalAddressV4 +\n\t\t\t\t\", localAddressV6=\" + localAddressV6 +\n\t\t\t\t\", externalAddressV6=\" + externalAddressV6 +\n\t\t\t\t\", currentConnectAddress=\" + currentConnectAddress +\n\t\t\t\t\", hostname='\" + hostname + '\\'' +\n\t\t\t\t\", localAddressList=\" + localAddressList +\n\t\t\t\t\", externalAddressList=\" + externalAddressList +\n\t\t\t\t'}';\n\t}\n\n\n\tpublic static final class Builder\n\t{\n\t\tprivate long pgpIdentifier;\n\t\tprivate LocationIdentifier locationIdentifier;\n\t\tprivate String location;\n\t\tprivate String version;\n\t\tprivate NetMode netMode;\n\t\tprivate short vsDisc;\n\t\tprivate short vsDht;\n\t\tprivate int lastContact;\n\t\tprivate String hiddenAddress;\n\t\tprivate short hiddenPort;\n\t\tprivate PeerAddress localAddressV4;\n\t\tprivate PeerAddress externalAddressV4;\n\t\tprivate PeerAddress localAddressV6;\n\t\tprivate PeerAddress externalAddressV6;\n\t\tprivate PeerAddress currentConnectAddress;\n\t\tprivate String hostname;\n\t\tprivate List<PeerAddress> localAddressList;\n\t\tprivate List<PeerAddress> externalAddressList;\n\n\t\tprivate Builder()\n\t\t{\n\t\t}\n\n\t\tpublic Builder setPgpIdentifier(long pgpIdentifier)\n\t\t{\n\t\t\tthis.pgpIdentifier = pgpIdentifier;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t\t{\n\t\t\tthis.locationIdentifier = locationIdentifier;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setLocationName(String locationName)\n\t\t{\n\t\t\tlocation = locationName;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setVersion(String version)\n\t\t{\n\t\t\tthis.version = version;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setNetMode(NetMode netMode)\n\t\t{\n\t\t\tthis.netMode = netMode;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setVsDisc(int vsDisc)\n\t\t{\n\t\t\tthis.vsDisc = (short) vsDisc;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setVsDht(int vsDht)\n\t\t{\n\t\t\tthis.vsDht = (short) vsDht;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setLastContact(int lastContact)\n\t\t{\n\t\t\tthis.lastContact = lastContact;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setHiddenAddress(String hiddenAddress)\n\t\t{\n\t\t\tthis.hiddenAddress = hiddenAddress;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setHiddenPort(short hiddenPort)\n\t\t{\n\t\t\tthis.hiddenPort = hiddenPort;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setLocalAddressV4(PeerAddress localAddressV4)\n\t\t{\n\t\t\tthis.localAddressV4 = localAddressV4;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setExternalAddressV4(PeerAddress externalAddressV4)\n\t\t{\n\t\t\tthis.externalAddressV4 = externalAddressV4;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setLocalAddressV6(PeerAddress localAddressV6)\n\t\t{\n\t\t\tthis.localAddressV6 = localAddressV6;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setExternalAddressV6(PeerAddress externalAddressV6)\n\t\t{\n\t\t\tthis.externalAddressV6 = externalAddressV6;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setCurrentConnectAddress(PeerAddress currentConnectAddress)\n\t\t{\n\t\t\tthis.currentConnectAddress = currentConnectAddress;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setHostname(String hostname)\n\t\t{\n\t\t\tthis.hostname = hostname;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setLocalAddressList(List<PeerAddress> localAddressList)\n\t\t{\n\t\t\tthis.localAddressList = localAddressList;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setExternalAddressList(List<PeerAddress> externalAddressList)\n\t\t{\n\t\t\tthis.externalAddressList = externalAddressList;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic DiscoveryContactItem build()\n\t\t{\n\t\t\treturn new DiscoveryContactItem(this);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryIdentityListItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static java.util.stream.Collectors.joining;\n\npublic class DiscoveryIdentityListItem extends Item\n{\n\t@RsSerialized\n\tprivate final List<GxsId> identities = new ArrayList<>();\n\n\t@SuppressWarnings(\"unused\")\n\tpublic DiscoveryIdentityListItem()\n\t{\n\t}\n\n\tpublic DiscoveryIdentityListItem(List<GxsId> identities)\n\t{\n\t\tthis.identities.addAll(identities);\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.DISCOVERY.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 6;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.BACKGROUND.getPriority();\n\t}\n\n\tpublic List<GxsId> getIdentities()\n\t{\n\t\treturn identities;\n\t}\n\n\t@Override\n\tpublic DiscoveryIdentityListItem clone()\n\t{\n\t\treturn (DiscoveryIdentityListItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"DiscoveryIdentityListItem{\" +\n\t\t\t\t\"identities=\" + identities.stream().map(Object::toString).collect(joining(\", \")) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryPgpKeyItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class DiscoveryPgpKeyItem extends Item\n{\n\t@RsSerialized\n\tprivate long pgpIdentifier;\n\n\t@RsSerialized\n\tprivate byte[] keyData;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic DiscoveryPgpKeyItem()\n\t{\n\t}\n\n\tpublic DiscoveryPgpKeyItem(long pgpIdentifier, byte[] keyData)\n\t{\n\t\tthis.pgpIdentifier = pgpIdentifier;\n\t\tthis.keyData = keyData;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.DISCOVERY.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 9;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.BACKGROUND.getPriority();\n\t}\n\n\tpublic long getPgpIdentifier()\n\t{\n\t\treturn pgpIdentifier;\n\t}\n\n\tpublic byte[] getKeyData()\n\t{\n\t\treturn keyData;\n\t}\n\n\t@Override\n\tpublic DiscoveryPgpKeyItem clone()\n\t{\n\t\treturn (DiscoveryPgpKeyItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"DiscoveryPgpKeyItem{\" +\n\t\t\t\t\"pgpIdentifier=\" + Id.toString(pgpIdentifier) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/discovery/item/DiscoveryPgpListItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\nimport static io.xeres.app.xrs.serialization.TlvType.SET_PGP_ID;\nimport static java.util.stream.Collectors.joining;\n\npublic class DiscoveryPgpListItem extends Item implements RsSerializable\n{\n\tpublic enum Mode\n\t{\n\t\tNONE,\n\t\tFRIENDS,\n\t\tGET_CERT\n\t}\n\n\tprivate Mode mode;\n\tprivate Set<Long> pgpIds = new HashSet<>();\n\n\t@SuppressWarnings(\"unused\")\n\tpublic DiscoveryPgpListItem()\n\t{\n\t}\n\n\tpublic DiscoveryPgpListItem(Mode mode, Set<Long> pgpIds)\n\t{\n\t\tthis.mode = mode;\n\t\tthis.pgpIds = pgpIds;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.DISCOVERY.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.BACKGROUND.getPriority();\n\t}\n\n\tpublic Mode getMode()\n\t{\n\t\treturn mode;\n\t}\n\n\tpublic Set<Long> getPgpIds()\n\t{\n\t\treturn Collections.unmodifiableSet(pgpIds);\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, mode);\n\t\tsize += serialize(buf, SET_PGP_ID, pgpIds);\n\t\treturn size;\n\t}\n\n\t@Override\n\t@SuppressWarnings(\"unchecked\")\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tmode = deserializeEnum(buf, Mode.class);\n\t\tpgpIds = (Set<Long>) deserialize(buf, SET_PGP_ID);\n\t}\n\n\t@Override\n\tpublic DiscoveryPgpListItem clone()\n\t{\n\t\treturn (DiscoveryPgpListItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"DiscoveryPgpListItem{\" +\n\t\t\t\t\"mode=\" + mode +\n\t\t\t\t\", pgpIds=\" + pgpIds.stream().map(Id::toString).collect(joining(\", \")) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/Action.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nsealed interface Action permits ActionAddPeer, ActionDownload, ActionGetDownloadsProgress, ActionGetUploadsProgress, ActionReceiveChunkMap, ActionReceiveChunkMapRequest, ActionReceiveData, ActionReceiveDataRequest, ActionReceiveSingleChunkCrc, ActionReceiveSingleChunkCrcRequest, ActionRemoveDownload, ActionRemovePeer\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionAddPeer.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionAddPeer(Sha1Sum hash, Location location) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionDownload.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.util.BitSet;\n\nrecord ActionDownload(long id, String name, Sha1Sum hash, long size, LocationIdentifier locationIdentifier, BitSet chunkMap) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionGetDownloadsProgress.java",
    "content": "package io.xeres.app.xrs.service.filetransfer;\n\nrecord ActionGetDownloadsProgress() implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionGetUploadsProgress.java",
    "content": "package io.xeres.app.xrs.service.filetransfer;\n\nrecord ActionGetUploadsProgress() implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveChunkMap.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.util.List;\n\nrecord ActionReceiveChunkMap(Location location, Sha1Sum hash, List<Integer> compressedChunkMap) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveChunkMapRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionReceiveChunkMapRequest(Location location, Sha1Sum hash, boolean isLeecher) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveData.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionReceiveData(Location location, Sha1Sum hash, long offset, byte[] data) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveDataRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionReceiveDataRequest(Location location, Sha1Sum hash, long offset, int chunkSize) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveSingleChunkCrc.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionReceiveSingleChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionReceiveSingleChunkCrcRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionReceiveSingleChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionRemoveDownload.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nrecord ActionRemoveDownload(long id) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ActionRemovePeer.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nrecord ActionRemovePeer(Sha1Sum hash, Location location) implements Action\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/Chunk.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE;\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE;\n\n/**\n * Represents a chunk. Is made up of several blocks of data.\n */\nclass Chunk\n{\n\t// hiBlocks and lowBlocks aren't necessary, but they could be used to re-ask only for the missing block instead of the whole chunk\n\tprivate long hiBlocks;\n\tprivate long lowBlocks;\n\tprivate final int totalBlocks;\n\tprivate int remainingBlocks;\n\n\t/**\n\t * Creates a chunk.\n\t *\n\t * @param size is at most {@link FileTransferRsService#CHUNK_SIZE} but can be less if the end of the file is within the last chunk\n\t */\n\tpublic Chunk(long size)\n\t{\n\t\tif (size > CHUNK_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Chunk size is greater than \" + CHUNK_SIZE);\n\t\t}\n\t\ttotalBlocks = (int) (size / BLOCK_SIZE + (size % BLOCK_SIZE != 0 ? 1 : 0));\n\t\tremainingBlocks = totalBlocks;\n\t}\n\n\t/**\n\t * Marks the block as written.\n\t *\n\t * @param offset the offset within the file\n\t * @param size the total written size\n\t */\n\tpublic void setBlocksAsWritten(long offset, int size)\n\t{\n\t\tif (offset % BLOCK_SIZE != 0)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Wrong block offset: \" + offset);\n\t\t}\n\n\t\twhile (size > 0)\n\t\t{\n\t\t\tvar blockOffset = offset % CHUNK_SIZE;\n\t\t\tvar blockIndex = blockOffset / BLOCK_SIZE;\n\t\t\tif (blockIndex < 64)\n\t\t\t{\n\t\t\t\tif ((lowBlocks & 1L << blockIndex) > 0)\n\t\t\t\t{\n\t\t\t\t\treturn; // Already set\n\t\t\t\t}\n\t\t\t\tlowBlocks |= 1L << blockIndex;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif ((hiBlocks & 1L << blockIndex - 64) > 0)\n\t\t\t\t{\n\t\t\t\t\treturn; // Already set\n\t\t\t\t}\n\t\t\t\thiBlocks |= 1L << blockIndex - 64;\n\t\t\t}\n\t\t\tremainingBlocks--;\n\t\t\tsize -= BLOCK_SIZE;\n\t\t\toffset += BLOCK_SIZE;\n\t\t}\n\t}\n\n\t/**\n\t * Checks if the chunk has all data written to it.\n\t *\n\t * @return true if complete\n\t */\n\tpublic boolean isComplete()\n\t{\n\t\treturn remainingBlocks == 0;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Chunk{\" +\n\t\t\t\t\"hiBlocks=\" + hiBlocks +\n\t\t\t\t\", lowBlocks=\" + lowBlocks +\n\t\t\t\t\", totalBlocks=\" + totalBlocks +\n\t\t\t\t\", remainingBlocks=\" + remainingBlocks +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ChunkDistributor.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.ThreadLocalRandom;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.LINEAR;\n\n/**\n * Used to track which chunks are still remaining for a file to be complete.\n */\nclass ChunkDistributor\n{\n\tprivate static final int MAX_RANDOM_TRY = 10;\n\n\t/**\n\t * Time to consider a given chunk as \"lost\".\n\t * XXX: add a way to update that value when a write for that chunk is received. if possible, make the timeout shorter then\n\t */\n\tprivate static final Duration GIVEN_CHUNK_TIMEOUT = Duration.ofMinutes(10);\n\n\tprivate final BitSet chunkMap; // This is updated externally\n\tprivate final Map<Integer, Instant> givenChunks = new HashMap<>();\n\tprivate final int totalChunks;\n\tprivate final FileTransferStrategy fileTransferStrategy;\n\tprivate int minChunk;\n\tprivate int maxChunk;\n\n\tpublic ChunkDistributor(BitSet chunkMap, int totalChunks, FileTransferStrategy fileTransferStrategy)\n\t{\n\t\tObjects.requireNonNull(chunkMap);\n\t\tif (totalChunks < 1)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"totalChunks must be greater than 0\");\n\t\t}\n\t\tthis.chunkMap = chunkMap;\n\t\tthis.totalChunks = totalChunks;\n\t\tthis.fileTransferStrategy = fileTransferStrategy;\n\t}\n\n\tprivate void updateChunksInfo()\n\t{\n\t\tminChunk = chunkMap.nextClearBit(Math.max(minChunk, 0));\n\t\tmaxChunk = chunkMap.previousClearBit(totalChunks - 1);\n\n\t\t// The given chunks that were downloaded should be\n\t\t// removed to consolidate the set.\n\t\tvar beforeSize = givenChunks.size();\n\t\tgivenChunks.entrySet().removeIf(entry -> chunkMap.get(entry.getKey()) || givenChunkIsTooOld(entry.getValue()));\n\t\tif (fileTransferStrategy == LINEAR && beforeSize != givenChunks.size())\n\t\t{\n\t\t\tminChunk = findMinChunk();\n\t\t}\n\t}\n\n\tprivate boolean givenChunkIsTooOld(Instant given)\n\t{\n\t\treturn given.isBefore(Instant.now().minus(GIVEN_CHUNK_TIMEOUT));\n\t}\n\n\tprivate int findMinChunk()\n\t{\n\t\tminChunk = chunkMap.nextClearBit(0);\n\t\twhile (givenChunks.containsKey(minChunk) || chunkMap.get(minChunk))\n\t\t{\n\t\t\tminChunk++;\n\t\t}\n\t\tif (minChunk > maxChunk)\n\t\t{\n\t\t\tminChunk = -1;\n\t\t}\n\t\treturn minChunk;\n\t}\n\n\t/**\n\t * Gets a next available chunk to fill in.\n\t *\n\t * @return an empty chunk which needs to be filled in\n\t */\n\tpublic Optional<Integer> getNextChunk(BitSet availableChunks)\n\t{\n\t\tupdateChunksInfo();\n\n\t\t// When maxChunk is -1, there's no free chunk left.\n\t\t// minChunk has a wrong value in that case because BitSet has no\n\t\t// concept of maximum bits, so it will always find a \"free\" bit.\n\t\tif (maxChunk == -1 || minChunk == -1 || chunkMap.cardinality() + givenChunks.size() == totalChunks)\n\t\t{\n\t\t\treturn Optional.empty();\n\t\t}\n\n\t\tvar chunk = fileTransferStrategy == LINEAR ? getLinearChunk() : getRandomChunk();\n\t\tif (!availableChunks.get(chunk))\n\t\t{\n\t\t\treturn Optional.empty();\n\t\t}\n\t\tgivenChunks.put(chunk, Instant.now());\n\t\treturn Optional.of(chunk);\n\t}\n\n\tprivate int getLinearChunk()\n\t{\n\t\tif (givenChunks.containsKey(minChunk) || chunkMap.get(minChunk))\n\t\t{\n\t\t\tminChunk++;\n\t\t}\n\t\treturn minChunk;\n\t}\n\n\tprivate int getRandomChunk()\n\t{\n\t\tint chunk;\n\t\tvar attempt = 0;\n\n\t\tdo\n\t\t{\n\t\t\tchunk = ThreadLocalRandom.current().nextInt(minChunk, maxChunk + 1);\n\t\t}\n\t\twhile (givenChunks.containsKey(chunk) && attempt++ < MAX_RANDOM_TRY);\n\n\t\tif (givenChunks.containsKey(chunk))\n\t\t{\n\t\t\tfor (int i = minChunk; i <= maxChunk; i++)\n\t\t\t{\n\t\t\t\tif (!givenChunks.containsKey(i))\n\t\t\t\t{\n\t\t\t\t\treturn i;\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow new IllegalStateException(\"Couldn't return random chunk. Shouldn't happen\");\n\t\t}\n\t\treturn chunk;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ChunkMapUtils.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport java.nio.ByteBuffer;\nimport java.nio.ByteOrder;\nimport java.util.Arrays;\nimport java.util.BitSet;\nimport java.util.List;\n\nfinal class ChunkMapUtils\n{\n\tprivate ChunkMapUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Converts the chunkMap to the format used by RS. Note that there might\n\t * be spurious unset chunks at the end. This is normal and RS also does that\n\t * because the file size is taken into account when searching chunks.\n\t *\n\t * @param chunkMap the chunk map\n\t * @return a compressed chunk map\n\t */\n\tstatic List<Integer> toCompressedChunkMap(BitSet chunkMap)\n\t{\n\t\tvar intBuf = ByteBuffer.wrap(alignArray(chunkMap.toByteArray()))\n\t\t\t\t.order(ByteOrder.LITTLE_ENDIAN)\n\t\t\t\t.asIntBuffer();\n\t\tvar ints = new int[intBuf.remaining()];\n\t\tintBuf.get(ints);\n\t\treturn Arrays.stream(ints).boxed().toList();\n\t}\n\n\tstatic BitSet toBitSet(List<Integer> chunkMap)\n\t{\n\t\tvar bitSet = new BitSet(chunkMap.size() * 32);\n\t\tfor (var i = 0; i < chunkMap.size(); i++)\n\t\t{\n\t\t\tvar value = chunkMap.get(i);\n\n\t\t\tfor (var j = 0; j < 32; j++)\n\t\t\t{\n\t\t\t\tbitSet.set(i * 32 + j, (value & (1 << j)) != 0);\n\t\t\t}\n\t\t}\n\t\treturn bitSet;\n\t}\n\n\t/**\n\t * Aligns the array to an integer (32-bits) boundary.\n\t *\n\t * @param src the source array\n\t * @return the array aligned to an integer boundary\n\t */\n\tprivate static byte[] alignArray(byte[] src)\n\t{\n\t\tif (src.length % 4 != 0)\n\t\t{\n\t\t\tvar dst = new byte[src.length + (4 - src.length % 4)];\n\t\t\tSystem.arraycopy(src, 0, dst, 0, src.length);\n\t\t\treturn dst;\n\t\t}\n\t\treturn src;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/ChunkReceiver.java",
    "content": "package io.xeres.app.xrs.service.filetransfer;\n\nimport java.util.BitSet;\n\nclass ChunkReceiver\n{\n\tprivate boolean receiving;\n\tprivate int chunkNumber;\n\tprivate BitSet chunkMap;\n\n\tpublic boolean isReceiving()\n\t{\n\t\treturn receiving;\n\t}\n\n\tpublic void setReceiving(boolean receiving)\n\t{\n\t\tthis.receiving = receiving;\n\t}\n\n\tpublic int getChunkNumber()\n\t{\n\t\treturn chunkNumber;\n\t}\n\n\tpublic void setChunkNumber(int chunkNumber)\n\t{\n\t\tthis.chunkNumber = chunkNumber;\n\t}\n\n\tpublic boolean hasChunkMap()\n\t{\n\t\treturn chunkMap != null;\n\t}\n\n\tpublic BitSet getChunkMap()\n\t{\n\t\treturn chunkMap;\n\t}\n\n\tpublic void setChunkMap(BitSet chunkMap)\n\t{\n\t\tthis.chunkMap = chunkMap;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileDownload.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.util.OsUtils;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.io.RandomAccessFile;\nimport java.nio.ByteBuffer;\nimport java.nio.file.Files;\nimport java.util.BitSet;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Optional;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE;\nimport static java.nio.file.StandardOpenOption.*;\n\n/**\n * This implementation of {@link FileProvider} is for downloading a file.\n */\nclass FileDownload extends FileUpload\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileDownload.class);\n\tprivate RandomAccessFile randomAccessFile;\n\n\tprivate final long id;\n\tprivate final BitSet chunkMap;\n\tprivate final int nBits;\n\tprivate final ChunkDistributor chunkDistributor;\n\tprivate final Map<Integer, Chunk> chunks = new HashMap<>();\n\tprivate long bytesWritten;\n\n\tpublic FileDownload(long id, File file, long size, BitSet chunkMap, FileTransferStrategy fileTransferStrategy)\n\t{\n\t\tsuper(file);\n\t\tthis.id = id;\n\t\tfileSize = size;\n\t\tnBits = (int) (size / CHUNK_SIZE + (size % CHUNK_SIZE != 0 ? 1 : 0));\n\t\tthis.chunkMap = chunkMap != null ? chunkMap : new BitSet(nBits);\n\t\tbytesWritten = (long) this.chunkMap.cardinality() * CHUNK_SIZE;\n\t\tchunkDistributor = new ChunkDistributor(this.chunkMap, nBits, fileTransferStrategy);\n\t}\n\n\t@Override\n\tpublic boolean open()\n\t{\n\t\ttry\n\t\t{\n\t\t\tcreateSparseFile();\n\t\t\trandomAccessFile = new RandomAccessFile(file, \"rw\");\n\t\t\tOsUtils.setFileVisible(file.toPath(), false);\n\t\t\tensureSparseFile();\n\t\t\tchannel = randomAccessFile.getChannel();\n\t\t\tlock = channel.lock(); // Exclusive lock\n\t\t\treturn true;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't open file {} for writing\", file, e);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * This creates a sparse file on Windows.\n\t * <p>\n\t * The file must not exist and is then marked as such.\n\t * (Write once, run anywhere, my ass...).\n\t *\n\t * @throws IOException if some I/O error happens\n\t */\n\tprivate void createSparseFile() throws IOException\n\t{\n\t\tif (SystemUtils.IS_OS_WINDOWS && !file.exists())\n\t\t{\n\t\t\ttry (var seekableByteChannel = Files.newByteChannel(file.toPath(), CREATE_NEW, WRITE, SPARSE))\n\t\t\t{\n\t\t\t\tseekableByteChannel.position(fileSize - 1);\n\t\t\t\tseekableByteChannel.write(ByteBuffer.wrap(new byte[]{(byte) 0}));\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * This ensures the file is sparse. Basically on Linux and MacOS, we just have to\n\t * set the length, and it's sparse by default.\n\t *\n\t * @throws IOException if some I/O error happens\n\t */\n\tprivate void ensureSparseFile() throws IOException\n\t{\n\t\tif (!SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\trandomAccessFile.setLength(fileSize);\n\t\t}\n\t}\n\n\t@Override\n\tpublic byte[] read(long offset, int size) throws IOException\n\t{\n\t\tif (isChunkAvailable(offset, size))\n\t\t{\n\t\t\treturn super.read(offset, size);\n\t\t}\n\t\tthrow new IOException(\"File at offset \" + offset + \" with size \" + size + \" is not available yet.\");\n\t}\n\n\t@Override\n\tpublic void write(long offset, byte[] data) throws IOException\n\t{\n\t\tvar buf = ByteBuffer.wrap(data);\n\t\tvar size = channel.write(buf, offset);\n\t\tbytesWritten += size;\n\t\tif (size != data.length)\n\t\t{\n\t\t\tthrow new IOException(\"Failed to write data, requested size: \" + data.length + \", actually written: \" + size);\n\t\t}\n\t\tmarkBlocksAsWritten(offset, size);\n\t}\n\n\t@Override\n\tpublic void close()\n\t{\n\t\ttry\n\t\t{\n\t\t\tlock.close();\n\t\t\tchannel.close();\n\t\t\trandomAccessFile.close();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to close file {} properly\", file, e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void closeAndDelete()\n\t{\n\t\tclose();\n\t\ttry\n\t\t{\n\t\t\tFiles.delete(file.toPath());\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't delete file {} properly: {}\", file, e.getMessage());\n\t\t}\n\t}\n\n\t@Override\n\tpublic BitSet getChunkMap()\n\t{\n\t\treturn (BitSet) chunkMap.clone();\n\t}\n\n\t@Override\n\tpublic boolean isComplete()\n\t{\n\t\treturn chunkMap.cardinality() == nBits;\n\t}\n\n\t@Override\n\tpublic Optional<Integer> getNeededChunk(BitSet chunkMap)\n\t{\n\t\treturn chunkDistributor.getNextChunk(chunkMap);\n\t}\n\n\t@Override\n\tpublic boolean hasChunk(int index)\n\t{\n\t\treturn chunkMap.get(index);\n\t}\n\n\tprivate boolean isChunkAvailable(long offset, int chunkSize)\n\t{\n\t\tint chunkStart = (int) (offset / chunkSize);\n\t\tint chunkEnd = (int) ((offset + chunkSize) / chunkSize);\n\n\t\tif ((offset + chunkSize) % chunkSize != 0)\n\t\t{\n\t\t\tchunkEnd++;\n\t\t}\n\n\t\tfor (var i = chunkStart; i < chunkEnd; i++)\n\t\t{\n\t\t\tif (!chunkMap.get(i))\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\tprivate void markBlocksAsWritten(long offset, int size)\n\t{\n\t\tint chunkKey = (int) (offset / CHUNK_SIZE);\n\t\tvar chunk = chunks.computeIfAbsent(chunkKey, _ -> new Chunk(Math.min(CHUNK_SIZE, fileSize - offset)));\n\t\tchunk.setBlocksAsWritten(offset, size);\n\n\t\tif (chunk.isComplete())\n\t\t{\n\t\t\tchunkMap.set(chunkKey);\n\t\t\tchunks.remove(chunkKey);\n\t\t}\n\t}\n\n\t@Override\n\tpublic Sha1Sum computeHash(long offset)\n\t{\n\t\tthrow new IllegalStateException(\"Cannot compute hashes of files being downloaded\");\n\t}\n\n\t@Override\n\tpublic long getBytesWritten()\n\t{\n\t\treturn bytesWritten;\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileLeecher.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nclass FileLeecher extends FilePeer\n{\n\tprivate final List<SliceSender> sliceSenders = new ArrayList<>(2);\n\n\tFileLeecher(Location location)\n\t{\n\t\tsuper(location);\n\t}\n\n\tpublic void addSliceSender(SliceSender sender)\n\t{\n\t\tsliceSenders.add(sender);\n\t}\n\n\tpublic SliceSender getSliceSender()\n\t{\n\t\treturn sliceSenders.getFirst();\n\t}\n\n\tpublic void removeSliceSender(SliceSender sender)\n\t{\n\t\tsliceSenders.remove(sender);\n\t}\n\n\tpublic boolean hasNoMoreSlices()\n\t{\n\t\treturn sliceSenders.isEmpty();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FilePeer.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\n\nimport java.time.Duration;\nimport java.time.Instant;\n\n/**\n * Note: this class has a natural ordering that is inconsistent with equals.\n */\nabstract class FilePeer implements Comparable<FilePeer>\n{\n\tprivate final Location location;\n\n\tprivate Instant nextScheduling = Instant.EPOCH;\n\n\tFilePeer(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic Instant getNextScheduling()\n\t{\n\t\treturn nextScheduling;\n\t}\n\n\tpublic void addNextScheduling(Duration duration)\n\t{\n\t\tnextScheduling = Instant.now().plus(duration);\n\t}\n\n\t@Override\n\tpublic int compareTo(FilePeer o)\n\t{\n\t\treturn nextScheduling.compareTo(o.getNextScheduling());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileProvider.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.BitSet;\nimport java.util.Optional;\n\n/**\n * Represents a local file. Can be complete or being completed.\n */\ninterface FileProvider\n{\n\tlong getFileSize();\n\n\tboolean open();\n\n\tbyte[] read(long offset, int size) throws IOException;\n\n\tvoid write(long offset, byte[] data) throws IOException;\n\n\tvoid close();\n\n\tvoid closeAndDelete();\n\n\tBitSet getChunkMap();\n\n\tOptional<Integer> getNeededChunk(BitSet chunkMap);\n\n\tboolean hasChunk(int index);\n\n\tboolean isComplete();\n\n\tPath getPath();\n\n\tlong getBytesWritten();\n\n\tlong getId();\n\n\tSha1Sum computeHash(long offset);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileSeeder.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\n\nimport java.util.BitSet;\n\npublic class FileSeeder extends FilePeer\n{\n\tprivate final ChunkReceiver chunkReceiver = new ChunkReceiver();\n\n\tFileSeeder(Location location)\n\t{\n\t\tsuper(location);\n\t}\n\n\tpublic void updateChunkMap(BitSet chunkMap)\n\t{\n\t\tchunkReceiver.setChunkMap(chunkMap);\n\t}\n\n\tpublic void setReceiving(boolean receiving)\n\t{\n\t\tchunkReceiver.setReceiving(receiving);\n\t}\n\n\tpublic boolean isReceiving()\n\t{\n\t\treturn chunkReceiver.isReceiving();\n\t}\n\n\tpublic int getChunkNumber()\n\t{\n\t\treturn chunkReceiver.getChunkNumber();\n\t}\n\n\tpublic boolean hasChunkMap()\n\t{\n\t\treturn chunkReceiver.hasChunkMap();\n\t}\n\n\tpublic BitSet getChunkMap()\n\t{\n\t\treturn chunkReceiver.getChunkMap();\n\t}\n\n\tpublic void setChunkNumber(int chunkNumber)\n\t{\n\t\tchunkReceiver.setChunkNumber(chunkNumber);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferAgent.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.util.FileNameUtils;\nimport io.xeres.common.util.OsUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.nio.file.FileAlreadyExistsException;\nimport java.nio.file.Files;\nimport java.nio.file.InvalidPathException;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\n\n/**\n * Responsible for sending/receiving one file.\n * There can be several leechers or seeders per file.\n */\nclass FileTransferAgent\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileTransferAgent.class);\n\n\t/**\n\t * Time after which a download or upload is considered stale.\n\t */\n\tprivate static final long IDLE_TIME = Duration.ofMinutes(5).toNanos();\n\n\tprivate final FileTransferRsService fileTransferRsService;\n\tprivate final FileProvider fileProvider;\n\tprivate final Sha1Sum hash;\n\tprivate final String fileName;\n\tprivate boolean done;\n\tprivate long lastActivity;\n\tprivate boolean trusted;\n\n\tprivate final Map<Location, FileLeecher> leechers = new LinkedHashMap<>();\n\tprivate final Map<Location, FileSeeder> seeders = new LinkedHashMap<>();\n\n\tprivate final PriorityQueue<FilePeer> queue = new PriorityQueue<>();\n\n\tpublic FileTransferAgent(FileTransferRsService fileTransferRsService, String fileName, Sha1Sum hash, FileProvider fileProvider)\n\t{\n\t\tthis.fileTransferRsService = fileTransferRsService;\n\t\tthis.hash = hash;\n\t\tthis.fileProvider = fileProvider;\n\t\tthis.fileName = fileName;\n\t\tlastActivity = System.nanoTime();\n\t}\n\n\tpublic void setTrusted(boolean trusted)\n\t{\n\t\tthis.trusted = trusted;\n\t}\n\n\tpublic FileProvider getFileProvider()\n\t{\n\t\treturn fileProvider;\n\t}\n\n\tpublic String getFileName()\n\t{\n\t\treturn fileName;\n\t}\n\n\tpublic void addSeeder(Location peer)\n\t{\n\t\tseeders.computeIfAbsent(peer, _ -> {\n\t\t\tvar fileSeeder = new FileSeeder(peer);\n\t\t\tqueue.add(fileSeeder);\n\t\t\treturn fileSeeder;\n\t\t});\n\t\tfileTransferRsService.sendChunkMapRequest(peer, hash, false);\n\t}\n\n\tpublic void addLeecher(Location peer, long offset, int size)\n\t{\n\t\tleechers.computeIfAbsent(peer, _ -> {\n\t\t\tvar fileLeecher = new FileLeecher(peer);\n\t\t\tqueue.add(fileLeecher);\n\t\t\treturn fileLeecher;\n\t\t}).addSliceSender(new SliceSender(fileTransferRsService, peer, fileProvider, hash, fileProvider.getFileSize(), offset, size));\n\t}\n\n\tpublic void removePeer(Location peer)\n\t{\n\t\tFilePeer removed = seeders.remove(peer);\n\t\tif (removed == null)\n\t\t{\n\t\t\tremoved = leechers.remove(peer);\n\t\t}\n\n\t\tif (removed == null)\n\t\t{\n\t\t\tlog.warn(\"Removal of peer {} failed because it's not in the list. This shouldn't happen.\", peer);\n\t\t}\n\t\tqueue.remove(removed);\n\t}\n\n\t/**\n\t * Processes file transfers.\n\t *\n\t * @return true if processing, false if there's nothing to process\n\t */\n\tpublic boolean process()\n\t{\n\t\tprocessPeers();\n\t\treturn queue.isEmpty();\n\t}\n\n\tpublic void cancel()\n\t{\n\t\tif (!fileProvider.isComplete())\n\t\t{\n\t\t\tfileProvider.closeAndDelete();\n\t\t}\n\t}\n\n\tpublic void stop()\n\t{\n\t\tfileProvider.close();\n\t}\n\n\tpublic void addChunkMap(Location peer, BitSet chunkMap)\n\t{\n\t\tvar seeder = seeders.get(peer);\n\t\tif (seeder == null)\n\t\t{\n\t\t\tlog.error(\"Seeder not found for adding chunkmap\");\n\t\t\treturn;\n\t\t}\n\t\tseeder.updateChunkMap(chunkMap);\n\t}\n\n\t/**\n\t * Tells if an agent idle. That is, nothing has been sent or received\n\t * for more than 5 minutes.\n\t *\n\t * @return true if idle\n\t */\n\tpublic boolean isIdle()\n\t{\n\t\treturn System.nanoTime() - lastActivity > IDLE_TIME;\n\t}\n\n\tpublic boolean isDone() // XXX: isDone what? it's only when it's done loading, should be clearer\n\t{\n\t\treturn done;\n\t}\n\n\t/**\n\t * Returns the next desired processing.\n\t *\n\t * @return when the next processing happens, null if there's no processing needed\n\t */\n\tpublic Instant getNextProcessing()\n\t{\n\t\tvar filePeer = queue.peek();\n\t\tif (filePeer != null)\n\t\t{\n\t\t\treturn filePeer.getNextScheduling();\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate void processPeers()\n\t{\n\t\tvar filePeer = queue.poll();\n\t\tswitch (filePeer)\n\t\t{\n\t\t\tcase FileSeeder fileSeeder -> processSeeder(fileSeeder);\n\t\t\tcase FileLeecher fileLeecher -> processLeecher(fileLeecher);\n\t\t\tcase null ->\n\t\t\t{\n\t\t\t\t// Empty queue\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Unhandled peer class\");\n\t\t}\n\n\t}\n\n\tprivate void processSeeder(FileSeeder fileSeeder)\n\t{\n\t\tif (fileSeeder.isReceiving())\n\t\t{\n\t\t\tlastActivity = System.nanoTime();\n\t\t\tif (fileProvider.hasChunk(fileSeeder.getChunkNumber()))\n\t\t\t{\n\t\t\t\tlog.debug(\"Chunk {} is complete\", fileSeeder.getChunkNumber());\n\t\t\t\tfileSeeder.setReceiving(false);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (fileProvider.isComplete() && !done)\n\t\t\t{\n\t\t\t\tlog.debug(\"File is complete, size: {}, renaming to {}\", fileProvider.getFileSize(), fileName);\n\t\t\t\tstop();\n\t\t\t\tfileTransferRsService.markDownloadAsCompleted(hash);\n\t\t\t\tfileTransferRsService.deactivateTunnels(hash);\n\t\t\t\tvar newPath = renameFile(fileProvider.getPath(), fileName);\n\t\t\t\tsetFileSecurity(newPath);\n\t\t\t\tremovePeer(fileSeeder.getLocation());\n\t\t\t\tdone = true; // Prevents closing the file several times (we might have several seeders)\n\t\t\t\treturn; // Don't reinsert in the queue\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (fileSeeder.hasChunkMap())\n\t\t\t\t{\n\t\t\t\t\tgetNextChunk(fileSeeder.getChunkMap()).ifPresent(chunkNumber -> {\n\t\t\t\t\t\tlog.debug(\"Requesting chunk number {} to peer {}\", chunkNumber, fileSeeder.getLocation());\n\t\t\t\t\t\tfileTransferRsService.sendDataRequest(fileSeeder.getLocation(), hash, fileProvider.getFileSize(), (long) chunkNumber * FileTransferRsService.CHUNK_SIZE, FileTransferRsService.CHUNK_SIZE);\n\t\t\t\t\t\tfileSeeder.setChunkNumber(chunkNumber);\n\t\t\t\t\t\tfileSeeder.setReceiving(true);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Calculating the next computation would require guessing when we need to ask for the\n\t\t// next chunk. Right now we ask for 1 MB, but we should ask for smaller and progressively bigger (up to 1 MB).\n\t\taddNextScheduling(fileSeeder, Duration.ofMillis(250)); // XXX: use a real computation... not sure it needs to be done in each process*()... maybe in the processPeer() only? check...\n\t\t// XXX: also to know the bandwidth, we have to know to which tunnelId the virtual location maps to, then to which peer the tunnelId maps to and we finally got a bandwidth.\n\t\t// then we also need to take into account the number of tunnels that are shared through that peer... what a mess. maybe we should push that info when creating the FileSeeder/Leecher?\n\t}\n\n\tprivate void setFileSecurity(Path path)\n\t{\n\t\tif (path != null)\n\t\t{\n\t\t\tOsUtils.setFileSecurity(path, trusted);\n\t\t}\n\t}\n\n\tprivate void processLeecher(FileLeecher fileLeecher)\n\t{\n\t\tvar sliceSender = fileLeecher.getSliceSender();\n\t\tvar remaining = sliceSender.send();\n\t\tlastActivity = System.nanoTime();\n\t\tif (!remaining)\n\t\t{\n\t\t\t// We just remove the leecher here and nothing else. The fileTransferManager will close the file\n\t\t\t// when it's idle for some time, otherwise it would need to be reopened immediately for the\n\t\t\t// next slice.\n\t\t\tfileLeecher.removeSliceSender(sliceSender);\n\t\t\tif (fileLeecher.hasNoMoreSlices())\n\t\t\t{\n\t\t\t\tremovePeer(fileLeecher.getLocation());\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\t// Here we could calculate the best time to send the next slice (8 KB) without overflowing our bandwidth\n\t\taddNextScheduling(fileLeecher, Duration.ofMillis(50)); // XXX: see above. this is 160 KB/s...\n\t}\n\n\tprivate void addNextScheduling(FilePeer filePeer, Duration duration)\n\t{\n\t\tfilePeer.addNextScheduling(duration);\n\t\tqueue.offer(filePeer);\n\t}\n\n\tprivate static Path renameFile(Path filePath, String fileName)\n\t{\n\t\tvar success = false;\n\t\tPath path = null;\n\n\t\twhile (!success)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar newPath = filePath.resolveSibling(fileName);\n\t\t\t\tFiles.move(filePath, newPath);\n\t\t\t\tOsUtils.setFileVisible(newPath, true);\n\t\t\t\tsuccess = true;\n\t\t\t\tpath = newPath;\n\t\t\t}\n\t\t\tcatch (FileAlreadyExistsException _)\n\t\t\t{\n\t\t\t\tlog.warn(\"File name {} already exists, renaming...\", fileName);\n\t\t\t\tfileName = FileNameUtils.rename(fileName);\n\t\t\t}\n\t\t\tcatch (InvalidPathException _)\n\t\t\t{\n\t\t\t\tlog.warn(\"File name {} is invalid, trying to fix the characters...\", fileName);\n\t\t\t\tvar newFileName = OsUtils.sanitizeFileName(fileName);\n\t\t\t\tif (newFileName.equals(fileName))\n\t\t\t\t{\n\t\t\t\t\tfileName = \"InvalidFileName_RenameMe\";\n\t\t\t\t\tlog.error(\"Couldn't find a proper name for file {}, using: {}. Rename by hand and report\", filePath, fileName);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tfileName = newFileName;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Couldn't rename the file {} to {}\", filePath, fileName, e);\n\t\t\t\tsuccess = true; // This is really a failure, but there's nothing else we can do\n\t\t\t}\n\t\t}\n\t\treturn path;\n\t}\n\n\t/**\n\t * Gets the next available chunk.\n\t *\n\t * @return the chunk number\n\t */\n\tprivate Optional<Integer> getNextChunk(BitSet chunkMap)\n\t{\n\t\treturn fileProvider.getNeededChunk(chunkMap);\n\t}\n}"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferEncryptionKey.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.crypto.hash.sha256.Sha256MessageDigest;\nimport io.xeres.common.id.Sha1Sum;\n\nimport javax.crypto.SecretKey;\nimport java.io.Serial;\n\nclass FileTransferEncryptionKey implements SecretKey\n{\n\t@Serial\n\tprivate static final long serialVersionUID = 6540345707970134182L;\n\n\tprivate final byte[] encoded;\n\n\tpublic FileTransferEncryptionKey(Sha1Sum hash)\n\t{\n\t\tvar digest = new Sha256MessageDigest();\n\t\tdigest.update(hash.getBytes());\n\t\tencoded = digest.getBytes();\n\t}\n\n\t@Override\n\tpublic String getAlgorithm()\n\t{\n\t\treturn \"ChaCha20\";\n\t}\n\n\t@Override\n\tpublic String getFormat()\n\t{\n\t\treturn \"RAW\";\n\t}\n\n\t@Override\n\tpublic byte[] getEncoded()\n\t{\n\t\treturn encoded.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferManager.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.file.FileService;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.rest.file.FileProgress;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.BlockingQueue;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Stream;\n\nimport static io.xeres.app.service.file.FileService.DOWNLOAD_EXTENSION;\nimport static io.xeres.app.service.file.FileService.DOWNLOAD_PREFIX;\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE;\n\n/**\n * File transfer management class.\n * <p>\n * <img src=\"doc-files/filetransfer.svg\" alt=\"File transfer diagram\">\n * The FileTransferManager manages several uploads and downloads. Each of them is represented by one {@link FileTransferAgent}.\n * <p>\n * A FileTransferAgent is paired with a {@link FileProvider} that is either a {@link FileDownload} or a {@link FileUpload} depending on the role of\n * that agent (respectively, download or upload a file).\n * <p>\n * Each FileTransferAgent has a list of seeders and leechers for itself.\n * <p>\n * Leechers ask for a slice between 1 byte and 1 MB. The result is always sent in packets of 8 KB max.\n * The goal is to send at the optimum speed depending on our bandwidth, the peer's bandwidth and the peer's RTT.\n * <p>\n * For requesting, ask for a chunk size of some small size, then monitor the speed and RTT while asking for more. We shouldn't\n * overflow our bandwidth nor the peer's one. We should also ask ahead of time for optimum speed including between chunks.\n */\nclass FileTransferManager implements Runnable\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileTransferManager.class);\n\n\tprivate static final int DEFAULT_TICK = 1000;\n\n\tprivate final FileTransferRsService fileTransferRsService;\n\tprivate final FileService fileService;\n\tprivate final SettingsService settingsService;\n\tprivate final LocationService locationService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final Location ownLocation;\n\tprivate final BlockingQueue<Action> queue;\n\tprivate final FileTransferStrategy fileTransferStrategy;\n\n\tprivate final Map<Sha1Sum, FileTransferAgent> downloads = new HashMap<>(); // files that we are downloading (client)\n\tprivate final Map<Sha1Sum, FileTransferAgent> uploads = new HashMap<>(); // files that we are uploading (serving)\n\n\tprivate final List<FileProgress> downloadsProgress = new ArrayList<>();\n\tprivate final List<FileProgress> uploadsProgress = new ArrayList<>();\n\n\tpublic FileTransferManager(FileTransferRsService fileTransferRsService, FileService fileService, SettingsService settingsService, LocationService locationService, DatabaseSessionManager databaseSessionManager, Location ownLocation, BlockingQueue<Action> queue, FileTransferStrategy fileTransferStrategy)\n\t{\n\t\tthis.fileTransferRsService = fileTransferRsService;\n\t\tthis.fileService = fileService;\n\t\tthis.settingsService = settingsService;\n\t\tthis.locationService = locationService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.ownLocation = ownLocation;\n\t\tthis.queue = queue;\n\t\tthis.fileTransferStrategy = fileTransferStrategy;\n\t}\n\n\t@Override\n\tpublic void run()\n\t{\n\t\tvar done = false;\n\n\t\twhile (!done)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar action = getNextAction();\n\t\t\t\tprocessAction(action);\n\t\t\t\tprocessDownloads();\n\t\t\t\tprocessUploads();\n\t\t\t}\n\t\t\tcatch (InterruptedException _)\n\t\t\t{\n\t\t\t\tlog.debug(\"FileTransferManager thread interrupted\");\n\t\t\t\tcleanup();\n\t\t\t\tdone = true;\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void cleanup()\n\t{\n\t\tdownloads.forEach((hash, download) -> fileService.suspendDownload(hash, download.getFileProvider().getChunkMap()));\n\t}\n\n\tprivate Action getNextAction() throws InterruptedException\n\t{\n\t\tif (downloads.isEmpty() && uploads.isEmpty())\n\t\t{\n\t\t\treturn queue.take();\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn queue.poll(computeOptimalWaitingTime(), TimeUnit.MILLISECONDS);\n\t\t}\n\t}\n\n\tprivate long computeOptimalWaitingTime()\n\t{\n\t\tvar now = Instant.now();\n\t\tint minWaitingTime = DEFAULT_TICK;\n\n\t\tvar agents = Stream.concat(downloads.values().stream(), uploads.values().stream())\n\t\t\t\t.toList();\n\n\t\tfor (var agent : agents)\n\t\t{\n\t\t\tminWaitingTime = Math.min(minWaitingTime, durationBetween(now, agent.getNextProcessing()));\n\t\t\tif (minWaitingTime == 0)\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tlog.debug(\"Calculated optimal time: {}\", minWaitingTime);\n\t\treturn minWaitingTime;\n\t}\n\n\tprivate static int durationBetween(Instant now, Instant nextDelay)\n\t{\n\t\tif (nextDelay == null)\n\t\t{\n\t\t\treturn DEFAULT_TICK;\n\t\t}\n\t\tvar duration = Duration.between(now, nextDelay);\n\t\tif (duration.isNegative())\n\t\t{\n\t\t\treturn 0;\n\t\t}\n\t\treturn safeLongToInt(duration.toMillis());\n\t}\n\n\tprivate static int safeLongToInt(long value)\n\t{\n\t\tif (value > Integer.MAX_VALUE)\n\t\t{\n\t\t\treturn Integer.MAX_VALUE;\n\t\t}\n\t\treturn (int) value;\n\t}\n\n\tpublic List<FileProgress> getDownloadsProgress()\n\t{\n\t\tsynchronized (downloadsProgress)\n\t\t{\n\t\t\t//noinspection unchecked\n\t\t\treturn (List<FileProgress>) ((ArrayList<FileProgress>) downloadsProgress).clone();\n\t\t}\n\t}\n\n\tpublic List<FileProgress> getUploadsProgress()\n\t{\n\t\tsynchronized (uploadsProgress)\n\t\t{\n\t\t\t//noinspection unchecked\n\t\t\treturn (List<FileProgress>) ((ArrayList<FileProgress>) uploadsProgress).clone();\n\t\t}\n\t}\n\n\tprivate void processDownloads()\n\t{\n\t\tdownloads.forEach((_, download) -> download.process());\n\t}\n\n\tprivate void processUploads()\n\t{\n\t\tuploads.entrySet().removeIf(upload -> stopStalledUpload(upload.getValue()));\n\t\tuploads.forEach((_, upload) -> upload.process());\n\t}\n\n\tprivate boolean stopStalledUpload(FileTransferAgent upload)\n\t{\n\t\tif (upload.isIdle())\n\t\t{\n\t\t\tupload.stop();\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate void processAction(Action action)\n\t{\n\t\tswitch (action)\n\t\t{\n\t\t\tcase ActionAddPeer(Sha1Sum hash, Location location) -> actionAddPeer(hash, location);\n\t\t\tcase ActionRemovePeer(Sha1Sum hash, Location location) -> actionRemovePeer(hash, location);\n\n\t\t\tcase ActionReceiveDataRequest(Location location, Sha1Sum hash, long offset, int chunkSize) -> actionReceiveDataRequest(location, hash, offset, chunkSize);\n\t\t\tcase ActionReceiveData(Location location, Sha1Sum hash, long offset, byte[] data) -> actionReceiveData(location, hash, offset, data);\n\n\t\t\tcase ActionDownload(long id, String name, Sha1Sum hash, long size, LocationIdentifier from, BitSet chunkMap) -> actionDownload(id, name, hash, size, from, chunkMap);\n\t\t\tcase ActionRemoveDownload(long id) -> actionRemoveDownload(id);\n\n\t\t\tcase ActionGetDownloadsProgress() -> actionComputeDownloadsProgress();\n\t\t\tcase ActionGetUploadsProgress() -> actionComputeUploadsProgress();\n\n\t\t\tcase ActionReceiveChunkMapRequest(Location location, Sha1Sum hash, boolean isLeecher) -> actionReceiveChunkMapRequest(location, hash, isLeecher);\n\t\t\tcase ActionReceiveChunkMap(Location location, Sha1Sum hash, List<Integer> compressedChunkMap) -> actionReceiveChunkMap(location, hash, compressedChunkMap);\n\n\t\t\tcase ActionReceiveSingleChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber) -> actionReceiveChunkCrcRequest(location, hash, chunkNumber);\n\t\t\tcase ActionReceiveSingleChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum) -> actionReceiveChunkCrc(location, hash, chunkNumber, checkSum);\n\t\t\tcase null ->\n\t\t\t{\n\t\t\t\t// This is the return from a timeout. Nothing to do.\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void actionDownload(long id, String name, Sha1Sum hash, long size, LocationIdentifier from, BitSet chunkMap)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tdownloads.computeIfAbsent(hash, sha1Sum -> {\n\t\t\t\tvar file = Paths.get(settingsService.getIncomingDirectory(), DOWNLOAD_PREFIX + sha1Sum + DOWNLOAD_EXTENSION).toFile();\n\t\t\t\tlog.debug(\"Downloading file {}, size: {}, from: {}\", file, size, from);\n\t\t\t\tvar fileDownload = new FileDownload(id, file, size, chunkMap, from != null ? FileTransferStrategy.LINEAR : fileTransferStrategy);\n\t\t\t\tif (fileDownload.open())\n\t\t\t\t{\n\t\t\t\t\tvar download = new FileTransferAgent(fileTransferRsService, name, sha1Sum, fileDownload);\n\t\t\t\t\tif (from != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tdownload.setTrusted(true);\n\t\t\t\t\t\tlocationService.findLocationByLocationIdentifier(from).ifPresent(download::addSeeder);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tfileTransferRsService.activateTunnels(sha1Sum);\n\t\t\t\t\t}\n\t\t\t\t\treturn download;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Couldn't create file {} for download\", file);\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\tpublic void actionRemoveDownload(long id)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tfileService.findById(id).ifPresent(fileDownload -> {\n\t\t\t\tfileTransferRsService.deactivateTunnels(fileDownload.getHash());\n\t\t\t\tvar download = downloads.get(fileDownload.getHash());\n\t\t\t\tif (download != null)\n\t\t\t\t{\n\t\t\t\t\tdownload.cancel();\n\t\t\t\t\tdownloads.remove(fileDownload.getHash());\n\t\t\t\t}\n\t\t\t\tfileService.removeDownload(id);\n\t\t\t});\n\t\t}\n\t}\n\n\tprivate void actionComputeDownloadsProgress()\n\t{\n\t\tList<FileProgress> newDownloadList = new ArrayList<>(downloads.size());\n\t\tdownloads.forEach((sha1Sum, download) -> newDownloadList.add(\n\t\t\t\tnew FileProgress(download.getFileProvider().getId(),\n\t\t\t\t\t\tdownload.getFileName(),\n\t\t\t\t\t\tdownload.getFileProvider().getBytesWritten(),\n\t\t\t\t\t\tdownload.getFileProvider().getFileSize(),\n\t\t\t\t\t\tsha1Sum.toString(),\n\t\t\t\t\t\tdownload.isDone())));\n\n\t\tsynchronized (downloadsProgress)\n\t\t{\n\t\t\tdownloadsProgress.clear();\n\t\t\tdownloadsProgress.addAll(newDownloadList);\n\t\t}\n\t}\n\n\tprivate void actionComputeUploadsProgress()\n\t{\n\t\tList<FileProgress> newUploadList = new ArrayList<>(uploads.size());\n\t\tuploads.forEach((sha1Sum, upload) -> newUploadList.add(\n\t\t\t\tnew FileProgress(0L,\n\t\t\t\t\t\tupload.getFileName(),\n\t\t\t\t\t\t0L,\n\t\t\t\t\t\tupload.getFileProvider().getFileSize(),\n\t\t\t\t\t\tsha1Sum.toString(),\n\t\t\t\t\t\tupload.isDone())));\n\n\t\tsynchronized (uploadsProgress)\n\t\t{\n\t\t\tuploadsProgress.clear();\n\t\t\tuploadsProgress.addAll(newUploadList);\n\t\t}\n\t}\n\n\t/**\n\t * Adds a peer to one of our downloads.\n\t *\n\t * @param hash     the hash of the file being downloaded\n\t * @param location the source location to add\n\t */\n\tprivate void actionAddPeer(Sha1Sum hash, Location location)\n\t{\n\t\tvar download = downloads.get(hash);\n\t\tif (download != null)\n\t\t{\n\t\t\tdownload.addSeeder(location);\n\t\t}\n\t}\n\n\t/**\n\t * Removes a peer from one of our downloads.\n\t *\n\t * @param hash     the hash of the file being downloaded\n\t * @param location the source location to remove\n\t */\n\tprivate void actionRemovePeer(Sha1Sum hash, Location location)\n\t{\n\t\tvar download = downloads.get(hash);\n\t\tif (download != null)\n\t\t{\n\t\t\tdownload.removePeer(location);\n\t\t}\n\t}\n\n\tprivate void actionReceiveDataRequest(Location location, Sha1Sum hash, long offset, int chunkSize)\n\t{\n\t\tlog.debug(\"Received data request from {}, hash: {}, offset: {}, chunkSize: {}\", location, hash, offset, chunkSize);\n\t\tFileTransferAgent upload;\n\n\t\t//noinspection StatementWithEmptyBody\n\t\tif (location.equals(ownLocation))\n\t\t{\n\t\t\t// Own requests must be passed to seeders\n\t\t}\n\t\telse\n\t\t{\n\t\t\tupload = uploads.get(hash);\n\t\t\tif (upload == null)\n\t\t\t{\n\t\t\t\tupload = localSearch(hash);\n\t\t\t}\n\t\t\tif (upload != null)\n\t\t\t{\n\t\t\t\thandleLeecherRequest(location, upload, hash, offset, chunkSize);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate FileTransferAgent localSearch(Sha1Sum hash)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\treturn uploads.computeIfAbsent(hash, h -> fileService.findFilePathByHash(h)\n\t\t\t\t\t.map(Path::toFile)\n\t\t\t\t\t.map(file -> {\n\t\t\t\t\t\tlog.debug(\"Serving file {} for hash {}\", file, hash);\n\t\t\t\t\t\tvar upload = new FileUpload(file);\n\t\t\t\t\t\tif (!upload.open())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.debug(\"Failed to open file {} for serving\", file);\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn new FileTransferAgent(fileTransferRsService, file.getName(), h, upload);\n\t\t\t\t\t})\n\t\t\t\t\t.orElse(null));\n\t\t}\n\t}\n\n\tprivate void actionReceiveData(Location location, Sha1Sum hash, long offset, byte[] data)\n\t{\n\t\tvar download = downloads.get(hash);\n\t\tif (download == null)\n\t\t{\n\t\t\tlog.error(\"No matching download agent for hash {}\", hash);\n\t\t\treturn;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tlog.trace(\"Writing file {}, offset: {}, length: {}\", download.getFileName(), offset, data.length);\n\t\t\t// XXX: update location stats for writing (see how RS does it)\n\t\t\tvar fileProvider = download.getFileProvider();\n\t\t\tfileProvider.write(offset, data);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to write to file\", e);\n\t\t}\n\t}\n\n\tprivate void actionReceiveChunkMapRequest(Location location, Sha1Sum hash, boolean isLeecher)\n\t{\n\t\tlog.debug(\"Received {} chunk map request from {}, hash: {}\", isLeecher ? \"leecher (client)\" : \"seeder (server)\", location, hash);\n\t\tif (isLeecher)\n\t\t{\n\t\t\tactionReceiveLeecherChunkMapRequest(location, hash);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tactionReceiveSeederChunkMapRequest(location, hash);\n\t\t}\n\t}\n\n\tprivate void actionReceiveChunkMap(Location location, Sha1Sum hash, List<Integer> compressedChunkMap)\n\t{\n\t\tlog.debug(\"Received chunk map from {}\", location);\n\t\tvar download = downloads.get(hash);\n\t\tif (download == null)\n\t\t{\n\t\t\tlog.error(\"No matching download agent for hash {} for chunk map\", hash);\n\t\t\treturn;\n\t\t}\n\t\tvar chunkMap = ChunkMapUtils.toBitSet(compressedChunkMap);\n\t\tdownload.addChunkMap(location, chunkMap);\n\t}\n\n\tprivate void actionReceiveLeecherChunkMapRequest(Location location, Sha1Sum hash)\n\t{\n\t\tvar download = downloads.get(hash);\n\t\tif (download == null)\n\t\t{\n\t\t\tlog.error(\"No matching download agent for hash {} for chunk map request\", hash);\n\t\t\treturn;\n\t\t}\n\t\tvar compressedChunkMap = ChunkMapUtils.toCompressedChunkMap(download.getFileProvider().getChunkMap());\n\t\tfileTransferRsService.sendChunkMap(location, hash, false, compressedChunkMap);\n\t}\n\n\tprivate void actionReceiveSeederChunkMapRequest(Location location, Sha1Sum hash)\n\t{\n\t\tvar upload = uploads.get(hash);\n\t\tif (upload == null)\n\t\t{\n\t\t\tupload = localSearch(hash);\n\t\t}\n\n\t\tif (upload == null)\n\t\t{\n\t\t\tlog.error(\"Chunk map request succeeded but no seeder available\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar compressedChunkMap = ChunkMapUtils.toCompressedChunkMap(upload.getFileProvider().getChunkMap());\n\t\tfileTransferRsService.sendChunkMap(location, hash, true, compressedChunkMap);\n\t}\n\n\tprivate void actionReceiveChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber)\n\t{\n\t\tlog.debug(\"Received chunk crc request from {}\", location);\n\t\tvar upload = uploads.get(hash);\n\t\tif (upload == null)\n\t\t{\n\t\t\tupload = localSearch(hash);\n\t\t}\n\n\t\tif (upload == null)\n\t\t{\n\t\t\tlog.error(\"No matching upload agent for hash {} for chunk number {}\", hash, chunkNumber);\n\t\t\treturn;\n\t\t}\n\n\t\t// XXX: add a cache, queue for serving them later, etc...\n\t\tvar checkSum = upload.getFileProvider().computeHash((long) chunkNumber * CHUNK_SIZE);\n\t\tif (checkSum != null)\n\t\t{\n\t\t\tfileTransferRsService.sendSingleChunkCrc(location, hash, chunkNumber, checkSum);\n\t\t}\n\t}\n\n\tprivate void actionReceiveChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum)\n\t{\n\t\tlog.debug(\"Received chunk crc from {}\", location);\n\t\t// XXX: handle! need to check leecher...\n\t}\n\n\tprivate static void handleLeecherRequest(Location location, FileTransferAgent upload, Sha1Sum hash, long offset, int chunkSize)\n\t{\n\t\tif (chunkSize > CHUNK_SIZE)\n\t\t{\n\t\t\tlog.warn(\"Peer {} is requesting a too large chunk ({}) for hash {}, ignoring\", location, chunkSize, hash);\n\t\t\treturn;\n\t\t}\n\t\t// XXX: update location stats for reading, see how RS does it\n\t\tupload.addLeecher(location, offset, chunkSize);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferRsService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.crypto.rscrypto.RsCrypto;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.repository.FileDownloadRepository;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.properties.NetworkProperties;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.file.FileService;\nimport io.xeres.app.service.notification.file.FileSearchNotificationService;\nimport io.xeres.app.service.notification.file.FileTrendNotificationService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemUtils;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.filetransfer.item.*;\nimport io.xeres.app.xrs.service.turtle.TurtleRouter;\nimport io.xeres.app.xrs.service.turtle.TurtleRsClient;\nimport io.xeres.app.xrs.service.turtle.item.*;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.rest.file.FileProgress;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.nio.file.Files;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.BlockingQueue;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.LinkedBlockingQueue;\n\nimport static io.xeres.app.properties.NetworkProperties.*;\nimport static io.xeres.common.protocol.xrs.RsServiceType.FILE_TRANSFER;\nimport static io.xeres.common.protocol.xrs.RsServiceType.TURTLE_ROUTER;\n\n@Component\npublic class FileTransferRsService extends RsService implements TurtleRsClient\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileTransferRsService.class);\n\tprivate TurtleRouter turtleRouter;\n\n\tstatic final int CHUNK_SIZE = 1024 * 1024; // 1 MB\n\tstatic final int BLOCK_SIZE = 1024 * 8; // 8 KB (warning: this got changed to 240 KB (!?) in recent RS)\n\n\tprivate final FileService fileService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final FileSearchNotificationService fileSearchNotificationService;\n\tprivate final FileTrendNotificationService fileTrendNotificationService;\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final LocationService locationService;\n\tprivate final SettingsService settingsService;\n\tprivate final RsCrypto.EncryptionFormat encryptionFormat;\n\tprivate final FileTransferStrategy fileTransferStrategy;\n\tprivate final FileDownloadRepository fileDownloadRepository;\n\tprivate FileTransferManager fileTransferManager;\n\tprivate Thread fileTransferManagerThread;\n\n\tprivate final BlockingQueue<Action> fileCommandQueue = new LinkedBlockingQueue<>();\n\n\tprivate final Map<Sha1Sum, Sha1Sum> encryptedHashes = new ConcurrentHashMap<>();\n\n\tpublic FileTransferRsService(RsServiceRegistry rsServiceRegistry, FileService fileService, PeerConnectionManager peerConnectionManager, FileSearchNotificationService fileSearchNotificationService, FileTrendNotificationService fileTrendNotificationService, DatabaseSessionManager databaseSessionManager, LocationService locationService, SettingsService settingsService, NetworkProperties networkProperties, FileDownloadRepository fileDownloadRepository)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.fileService = fileService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.fileSearchNotificationService = fileSearchNotificationService;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t\tthis.fileTrendNotificationService = fileTrendNotificationService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.locationService = locationService;\n\t\tthis.settingsService = settingsService;\n\t\tencryptionFormat = getEncryptionFormat(networkProperties);\n\t\tfileTransferStrategy = getFileTransferStrategy(networkProperties);\n\t\tthis.fileDownloadRepository = fileDownloadRepository;\n\t}\n\n\tprivate static RsCrypto.EncryptionFormat getEncryptionFormat(NetworkProperties networkProperties)\n\t{\n\t\tif (networkProperties.getTunnelEncryption().equals(TUNNEL_ENCRYPTION_CHACHA20_SHA256))\n\t\t{\n\t\t\treturn RsCrypto.EncryptionFormat.CHACHA20_SHA256;\n\t\t}\n\t\telse if (networkProperties.getTunnelEncryption().equals(TUNNEL_ENCRYPTION_CHACHA20_POLY1305))\n\t\t{\n\t\t\treturn RsCrypto.EncryptionFormat.CHACHA20_POLY1305;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported encryption format: \" + networkProperties.getTunnelEncryption());\n\t\t}\n\t}\n\n\tprivate static FileTransferStrategy getFileTransferStrategy(NetworkProperties networkProperties)\n\t{\n\t\tif (networkProperties.getFileTransferStrategy().equals(FILE_TRANSFER_STRATEGY_LINEAR))\n\t\t{\n\t\t\treturn FileTransferStrategy.LINEAR;\n\t\t}\n\t\telse if (networkProperties.getFileTransferStrategy().equals(FILE_TRANSFER_STRATEGY_RANDOM))\n\t\t{\n\t\t\treturn FileTransferStrategy.RANDOM;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported file transfer strategy: \" + networkProperties.getFileTransferStrategy());\n\t\t}\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tLocation ownLocation;\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\townLocation = locationService.findOwnLocation().orElseThrow();\n\t\t\tfileDownloadRepository.deleteAllByCompletedTrue();\n\t\t\tfileDownloadRepository.findAllByLocationIsNull()\n\t\t\t\t\t.forEach(file -> fileCommandQueue.add(new ActionDownload(file.getId(), file.getName(), file.getHash(), file.getSize(), null, file.getChunkMap())));\n\t\t}\n\n\t\tfileTransferManager = new FileTransferManager(this, fileService, settingsService, locationService, databaseSessionManager, ownLocation, fileCommandQueue, fileTransferStrategy);\n\n\t\tfileTransferManagerThread = Thread.ofVirtual()\n\t\t\t\t.name(\"File Transfer Manager\")\n\t\t\t\t.start(fileTransferManager);\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn FILE_TRANSFER;\n\t}\n\n\t@Override\n\tpublic RsServiceType getMasterServiceType()\n\t{\n\t\treturn TURTLE_ROUTER;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.NORMAL;\n\t}\n\n\t@Override\n\tpublic void initializeTurtle(TurtleRouter turtleRouter)\n\t{\n\t\tthis.turtleRouter = turtleRouter;\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tfileDownloadRepository.findAllByLocation(peerConnection.getLocation())\n\t\t\t\t\t.forEach(file -> fileCommandQueue.add(new ActionDownload(file.getId(), file.getName(), file.getHash(), file.getSize(), file.getLocation().getLocationIdentifier(), file.getChunkMap())));\n\t\t}\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tswitch (item)\n\t\t{\n\t\t\tcase FileTransferDataRequestItem ftItem -> // XXX: check for upload limit for this peer and drop it if exceeded!\n\t\t\t\t\tfileCommandQueue.add(new ActionReceiveDataRequest(sender.getLocation(), ftItem.getFileItem().hash(), ftItem.getFileOffset(), ftItem.getChunkSize()));\n\t\t\tcase FileTransferDataItem ftItem -> fileCommandQueue.add(new ActionReceiveData(sender.getLocation(), ftItem.getFileData().fileItem().hash(), ftItem.getFileData().offset(), ftItem.getFileData().data()));\n\n\t\t\tcase FileTransferChunkMapRequestItem ftItem -> fileCommandQueue.add(new ActionReceiveChunkMapRequest(sender.getLocation(), ftItem.getHash(), ftItem.isLeecher()));\n\t\t\tcase FileTransferChunkMapItem ftItem -> fileCommandQueue.add(new ActionReceiveChunkMap(sender.getLocation(), ftItem.getHash(), ftItem.getCompressedChunks()));\n\n\t\t\tcase FileTransferSingleChunkCrcRequestItem ftItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrcRequest(sender.getLocation(), ftItem.getHash(), ftItem.getChunkNumber()));\n\t\t\tcase FileTransferSingleChunkCrcItem ftItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrc(sender.getLocation(), ftItem.getHash(), ftItem.getChunkNumber(), ftItem.getCheckSum()));\n\t\t\tdefault -> log.debug(\"Unhandled item {}\", item);\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean handleTunnelRequest(PeerConnection sender, Sha1Sum hash)\n\t{\n\t\t// - find file by encrypted hash (and get its real hash)\n\t\t// - the correspondence can be put in the encryptedHashes, because the tunnel will likely be established\n\t\tvar file = fileService.findFileByEncryptedHash(hash);\n\t\tif (file.isPresent())\n\t\t{\n\t\t\tlog.debug(\"Found file {}\", file.get());\n\t\t\tvar path = fileService.getFilePath(file.get());\n\t\t\tif (!Files.isRegularFile(path))\n\t\t\t{\n\t\t\t\tlog.debug(\"File {} doesn't exist on disk, not serving it and removing\", file.get());\n\t\t\t\tfileService.deleteFile(file.get());\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\t// Add it to the encrypted hashes because it's going to be used soon\n\t\t\t// to establish the tunnels\n\t\t\tencryptedHashes.put(hash, file.get().getHash());\n\n\t\t\t// XXX: don't forget to handle files currently being swarmed and tons of other things\n\t\t\t// XXX: sender might not necessarily be needed (it's for the permissions)\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic void receiveTurtleData(TurtleGenericTunnelItem item, Sha1Sum hash, Location virtualLocation, TunnelDirection tunnelDirection)\n\t{\n\t\tswitch (item)\n\t\t{\n\t\t\tcase TurtleGenericDataItem turtleGenericDataItem ->\n\t\t\t{\n\t\t\t\tvar realHash = encryptedHashes.get(hash);\n\t\t\t\tif (realHash == null)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Cannot find the real hash of hash {}\", hash);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tvar decryptedItem = decryptItem(turtleGenericDataItem, realHash);\n\t\t\t\tif (decryptedItem instanceof TurtleGenericDataItem)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Decrypted item is a recursive bomb, dropping\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\treceiveTurtleData(decryptedItem, realHash, virtualLocation, tunnelDirection);\n\t\t\t\t}\n\t\t\t\t// No need to dispose decryptedItem as it doesn't come from netty\n\t\t\t}\n\n\t\t\tcase TurtleFileRequestItem turtleFileRequestItem -> fileCommandQueue.add(new ActionReceiveDataRequest(virtualLocation, hash, turtleFileRequestItem.getChunkOffset(), turtleFileRequestItem.getChunkSize()));\n\t\t\tcase TurtleFileDataItem turtleFileDataItem -> fileCommandQueue.add(new ActionReceiveData(virtualLocation, hash, turtleFileDataItem.getChunkOffset(), turtleFileDataItem.getChunkData()));\n\n\t\t\tcase TurtleFileMapRequestItem turtleFileMapRequestItem -> fileCommandQueue.add(new ActionReceiveChunkMapRequest(virtualLocation, hash, turtleFileMapRequestItem.getDirection() == TunnelDirection.CLIENT));\n\t\t\tcase TurtleFileMapItem turtleFileMapItem -> fileCommandQueue.add(new ActionReceiveChunkMap(virtualLocation, hash, turtleFileMapItem.getCompressedChunks()));\n\n\t\t\tcase TurtleChunkCrcRequestItem turtleChunkCrcRequestItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrcRequest(virtualLocation, hash, turtleChunkCrcRequestItem.getChunkNumber()));\n\t\t\tcase TurtleChunkCrcItem turtleChunkCrcItem -> fileCommandQueue.add(new ActionReceiveSingleChunkCrc(virtualLocation, hash, turtleChunkCrcItem.getChunkNumber(), turtleChunkCrcItem.getChecksum()));\n\n\t\t\tcase null -> throw new IllegalStateException(\"Null item\");\n\t\t\tdefault -> log.warn(\"Unknown packet type received: {}\", item.getSubType());\n\t\t}\n\t}\n\n\t@Override\n\tpublic List<byte[]> receiveSearchRequest(byte[] query, int maxHits)\n\t{\n\t\treturn List.of();\n\t}\n\n\t@Override\n\tpublic void receiveSearchRequestString(PeerConnection sender, String keywords)\n\t{\n\t\tfileTrendNotificationService.receivedSearch(sender.getLocation().getProfile().getName(), keywords);\n\t}\n\n\t@Override\n\tpublic void receiveSearchResult(int requestId, TurtleSearchResultItem item)\n\t{\n\t\tif (item instanceof TurtleFileSearchResultItem fileItem)\n\t\t{\n\t\t\tlog.debug(\"Forwarding search result id {} as notification\", requestId);\n\t\t\tfileItem.getResults().forEach(fileInfo -> fileSearchNotificationService.foundFile(requestId, fileInfo.getFileName(), fileInfo.getFileSize(), fileInfo.getFileHash()));\n\t\t}\n\t}\n\n\t@Override\n\tpublic void addVirtualPeer(Sha1Sum encryptedHash, Location virtualLocation, TunnelDirection direction)\n\t{\n\t\tvar hash = encryptedHashes.get(encryptedHash);\n\t\tif (hash == null)\n\t\t{\n\t\t\tlog.warn(\"Couldn't add virtual peer, not an encrypted hash\");\n\t\t\treturn;\n\t\t}\n\t\tif (direction == TunnelDirection.SERVER)\n\t\t{\n\t\t\tfileCommandQueue.add(new ActionAddPeer(hash, virtualLocation));\n\t\t}\n\t}\n\n\t@Override\n\tpublic void removeVirtualPeer(Sha1Sum encryptedHash, Location virtualLocation)\n\t{\n\t\tvar hash = encryptedHashes.get(encryptedHash);\n\t\tif (hash == null)\n\t\t{\n\t\t\tlog.warn(\"Couldn't remove virtual peer, not an encrypted hash\");\n\t\t\treturn;\n\t\t}\n\t\tfileCommandQueue.add(new ActionRemovePeer(hash, virtualLocation));\n\t}\n\n\tpublic int turtleSearch(String search) // XXX: maybe make a generic version or so...\n\t{\n\t\tif (turtleRouter != null) // Happens if the service is not enabled\n\t\t{\n\t\t\treturn turtleRouter.turtleSearch(search, this);\n\t\t}\n\t\treturn 0;\n\t}\n\n\t@Transactional\n\tpublic long download(String name, Sha1Sum hash, long size, LocationIdentifier locationIdentifier)\n\t{\n\t\tvar id = fileService.addDownload(name, hash, size, locationService.findLocationByLocationIdentifier(locationIdentifier).orElse(null));\n\t\tif (id != 0L)\n\t\t{\n\t\t\tfileCommandQueue.add(new ActionDownload(id, name, hash, size, locationIdentifier, null));\n\t\t}\n\t\treturn id;\n\t}\n\n\tpublic void markDownloadAsCompleted(Sha1Sum hash)\n\t{\n\t\tfileService.markDownloadAsCompleted(hash);\n\t}\n\n\tpublic List<FileProgress> getDownloadStatistics()\n\t{\n\t\tfileCommandQueue.add(new ActionGetDownloadsProgress());\n\t\treturn fileTransferManager.getDownloadsProgress();\n\t}\n\n\tpublic List<FileProgress> getUploadStatistics()\n\t{\n\t\tfileCommandQueue.add(new ActionGetUploadsProgress());\n\t\treturn fileTransferManager.getUploadsProgress();\n\t}\n\n\tpublic void removeDownload(long id)\n\t{\n\t\tfileCommandQueue.add(new ActionRemoveDownload(id));\n\t}\n\n\t@Override\n\tpublic void shutdown()\n\t{\n\t\tfileSearchNotificationService.shutdown();\n\t\tfileTrendNotificationService.shutdown();\n\t\tif (fileTransferManagerThread != null)\n\t\t{\n\t\t\tlog.info(\"Stopping FileTransferManager...\");\n\t\t\tfileTransferManagerThread.interrupt();\n\t\t\ttry\n\t\t\t{\n\t\t\t\tlog.info(\"Waiting for FileTransferManager to terminate...\");\n\t\t\t\tfileTransferManagerThread.join();\n\t\t\t\tlog.debug(\"FileTransferManager terminated\");\n\t\t\t}\n\t\t\tcatch (InterruptedException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Failed to wait for termination: {}\", e.getMessage(), e);\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void sendTurtleItem(Location virtualLocation, Sha1Sum hash, TurtleGenericTunnelItem item)\n\t{\n\t\t// We only send encrypted tunnels. They're available since Retroshare 0.6.2\n\t\tturtleRouter.sendTurtleData(virtualLocation, encryptItem(item, hash));\n\t}\n\n\tprivate TurtleGenericDataItem encryptItem(TurtleGenericTunnelItem item, Sha1Sum hash)\n\t{\n\t\tvar key = new FileTransferEncryptionKey(hash);\n\t\tvar serializedItem = ItemUtils.serializeItem(item, this);\n\t\treturn new TurtleGenericDataItem(RsCrypto.encryptAuthenticateData(key, serializedItem, encryptionFormat));\n\t}\n\n\tprivate TurtleGenericTunnelItem decryptItem(TurtleGenericDataItem item, Sha1Sum hash)\n\t{\n\t\tvar key = new FileTransferEncryptionKey(hash);\n\t\treturn (TurtleGenericTunnelItem) ItemUtils.deserializeItem(RsCrypto.decryptAuthenticateData(key, item.getTunnelData()), rsServiceRegistry);\n\t}\n\n\tpublic void activateTunnels(Sha1Sum hash)\n\t{\n\t\tvar encryptedHash = FileService.encryptHash(hash);\n\t\tencryptedHashes.put(encryptedHash, hash);\n\n\t\tturtleRouter.startMonitoringTunnels(encryptedHash, this, true);\n\t}\n\n\tpublic void deactivateTunnels(Sha1Sum hash)\n\t{\n\t\tvar encryptedHash = FileService.encryptHash(hash);\n\t\tencryptedHashes.put(encryptedHash, hash);\n\n\t\tturtleRouter.stopMonitoringTunnels(encryptedHash);\n\t}\n\n\t/**\n\t * Sends request as a client.\n\t *\n\t * @param location  the location to send to (can be virtual)\n\t * @param hash      the hash related to\n\t * @param size      the size\n\t * @param offset    the offset\n\t * @param chunkSize the chunk size (usually 1 MB)\n\t */\n\tpublic void sendDataRequest(Location location, Sha1Sum hash, long size, long offset, int chunkSize)\n\t{\n\t\tif (turtleRouter.isVirtualPeer(location))\n\t\t{\n\t\t\tvar item = new TurtleFileRequestItem(offset, chunkSize);\n\t\t\tsendTurtleItem(location, hash, item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar item = new FileTransferDataRequestItem(size, hash, offset, chunkSize);\n\t\t\tpeerConnectionManager.writeItem(location, item, this);\n\t\t}\n\t}\n\n\t/**\n\t * Sends a chunk map request.\n\t *\n\t * @param location the location to send to (can be virtual)\n\t * @param hash     the hash related to\n\t * @param isClient if true, means that the message is for a client (that is, one that is currently downloading the file) instead of a server\n\t */\n\tpublic void sendChunkMapRequest(Location location, Sha1Sum hash, boolean isClient)\n\t{\n\t\tif (turtleRouter.isVirtualPeer(location))\n\t\t{\n\t\t\tvar item = new TurtleFileMapRequestItem();\n\t\t\tsendTurtleItem(location, hash, item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar item = new FileTransferChunkMapRequestItem(hash, isClient);\n\t\t\tpeerConnectionManager.writeItem(location, item, this);\n\t\t}\n\t}\n\n\tvoid sendChunkMap(Location location, Sha1Sum hash, boolean isClient, List<Integer> compressedChunkMap)\n\t{\n\t\tif (turtleRouter.isVirtualPeer(location))\n\t\t{\n\t\t\tvar item = new TurtleFileMapItem(compressedChunkMap);\n\t\t\tsendTurtleItem(location, hash, item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar item = new FileTransferChunkMapItem(hash, compressedChunkMap, isClient);\n\t\t\tpeerConnectionManager.writeItem(location, item, this);\n\t\t}\n\t}\n\n\tpublic void sendSingleChunkCrcRequest(Location location, Sha1Sum hash, int chunkNumber)\n\t{\n\t\tif (turtleRouter.isVirtualPeer(location))\n\t\t{\n\t\t\tvar item = new TurtleChunkCrcRequestItem(chunkNumber);\n\t\t\tsendTurtleItem(location, hash, item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar item = new FileTransferSingleChunkCrcRequestItem(hash, chunkNumber);\n\t\t\tpeerConnectionManager.writeItem(location, item, this);\n\t\t}\n\t}\n\n\tpublic void sendSingleChunkCrc(Location location, Sha1Sum hash, int chunkNumber, Sha1Sum checkSum)\n\t{\n\t\tif (turtleRouter.isVirtualPeer(location))\n\t\t{\n\t\t\tvar item = new TurtleChunkCrcItem(chunkNumber, checkSum);\n\t\t\tsendTurtleItem(location, hash, item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar item = new FileTransferSingleChunkCrcItem(hash, chunkNumber, checkSum);\n\t\t\tpeerConnectionManager.writeItem(location, item, this);\n\t\t}\n\t}\n\n\t/**\n\t * Sends data as a server.\n\t *\n\t * @param location  the location to send to (can be virtual too)\n\t * @param hash      the hash related to it\n\t * @param totalSize the total size of the file\n\t * @param offset    the offset within the file\n\t * @param data      the data to send\n\t */\n\tvoid sendData(Location location, Sha1Sum hash, long totalSize, long offset, byte[] data)\n\t{\n\t\tif (data.length > 0)\n\t\t{\n\t\t\tif (data.length > BLOCK_SIZE)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Maximum send totalSize must be \" + BLOCK_SIZE + \", not \" + data.length);\n\t\t\t}\n\n\t\t\tif (turtleRouter.isVirtualPeer(location))\n\t\t\t{\n\t\t\t\tvar item = new TurtleFileDataItem(offset, data);\n\t\t\t\tsendTurtleItem(location, hash, item);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tvar item = new FileTransferDataItem(offset, totalSize, hash, data);\n\t\t\t\tpeerConnectionManager.writeItem(location, item, this);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Empty data, nothing to send. Bug?!\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileTransferStrategy.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\npublic enum FileTransferStrategy\n{\n\tLINEAR,\n\tRANDOM\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/FileUpload.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.common.id.Sha1Sum;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.nio.channels.FileChannel;\nimport java.nio.channels.FileLock;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.BitSet;\nimport java.util.Optional;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE;\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE;\n\n/**\n * This implementation of {@link FileProvider} is for uploading a file.\n */\nclass FileUpload implements FileProvider\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileUpload.class);\n\tprotected final File file;\n\tprotected FileChannel channel;\n\tprotected FileLock lock;\n\tprotected long fileSize;\n\tprivate BitSet chunkMap;\n\tprivate ByteBuffer buf;\n\n\tpublic FileUpload(File file)\n\t{\n\t\tthis.file = file;\n\t}\n\n\t@Override\n\tpublic long getFileSize()\n\t{\n\t\tif (channel == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"FileSeeder has not been initialized yet so the file size is not known\");\n\t\t}\n\t\treturn fileSize;\n\t}\n\n\t@Override\n\tpublic boolean open()\n\t{\n\t\ttry\n\t\t{\n\t\t\tchannel = FileChannel.open(file.toPath(), StandardOpenOption.READ);\n\t\t\tlock = channel.lock(0, Long.MAX_VALUE, true);\n\t\t\tif (!lock.isShared())\n\t\t\t{\n\t\t\t\tlog.warn(\"Lock for file {} is not shared\", file);\n\t\t\t}\n\t\t\tfileSize = channel.size();\n\t\t\treturn true;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't open file {} for reading\", file, e);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t@Override\n\tpublic byte[] read(long offset, int size) throws IOException // XXX: RS has an option to return unchecked chunks. not sure when it's used\n\t{\n\t\tif (size > BLOCK_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"size must be smaller than \" + BLOCK_SIZE + \" bytes\");\n\t\t}\n\t\tallocateBufferIfNeeded();\n\n\t\tbuf.clear();\n\t\tbuf.limit(size);\n\n\t\tchannel.read(buf, offset);\n\t\tvar a = new byte[buf.position()];\n\t\tbuf.flip();\n\t\tbuf.get(a);\n\t\treturn a;\n\t}\n\n\t@Override\n\tpublic Sha1Sum computeHash(long offset)\n\t{\n\t\tvar hashBuf = ByteBuffer.allocate(CHUNK_SIZE);\n\n\t\ttry\n\t\t{\n\t\t\tchannel.read(hashBuf, offset);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to compute hash: {}\", e.getMessage());\n\t\t\treturn null;\n\t\t}\n\n\t\tvar a = new byte[hashBuf.position()];\n\t\thashBuf.flip();\n\t\thashBuf.get(a);\n\n\t\tvar digest = new Sha1MessageDigest();\n\t\tdigest.update(a);\n\t\treturn digest.getSum();\n\t}\n\n\tprivate void allocateBufferIfNeeded()\n\t{\n\t\tif (buf == null)\n\t\t{\n\t\t\tbuf = ByteBuffer.allocate(BLOCK_SIZE);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void write(long offset, byte[] data) throws IOException\n\t{\n\t\tthrow new IllegalArgumentException(\"Cannot write data to a file provider\");\n\t}\n\n\t@Override\n\tpublic BitSet getChunkMap()\n\t{\n\t\tif (chunkMap == null)\n\t\t{\n\t\t\tvar numberOfChunks = getNumberOfChunks();\n\t\t\tchunkMap = new BitSet(numberOfChunks);\n\t\t\tchunkMap.set(0, numberOfChunks);\n\t\t}\n\t\treturn (BitSet) chunkMap.clone();\n\t}\n\n\tprotected int getNumberOfChunks()\n\t{\n\t\tvar numberOfChunks = fileSize / CHUNK_SIZE;\n\t\tif (fileSize % CHUNK_SIZE != 0)\n\t\t{\n\t\t\tnumberOfChunks++;\n\t\t}\n\t\tif (numberOfChunks > Integer.MAX_VALUE) // RS has a higher value because of unsigned ints. 4 TB instead of 2 TB\n\t\t{\n\t\t\tlog.error(\"Maximum chunk value exceeded. File size: {}. File won't be transferred properly\", fileSize);\n\t\t\treturn 0;\n\t\t}\n\t\treturn (int) numberOfChunks;\n\t}\n\n\t@Override\n\tpublic void close()\n\t{\n\t\ttry\n\t\t{\n\t\t\tlock.close();\n\t\t\tchannel.close();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to close file {} properly\", file, e);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void closeAndDelete()\n\t{\n\t\tthrow new IllegalStateException(\"Cannot delete a seeder\");\n\t}\n\n\t@Override\n\tpublic boolean isComplete()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic boolean hasChunk(int index)\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic Optional<Integer> getNeededChunk(BitSet chunkMap)\n\t{\n\t\treturn Optional.empty();\n\t}\n\n\t@Override\n\tpublic Path getPath()\n\t{\n\t\treturn file.toPath();\n\t}\n\n\t@Override\n\tpublic long getBytesWritten()\n\t{\n\t\tthrow new IllegalStateException(\"FileSeeder doesn't write bytes\");\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\tthrow new IllegalStateException(\"FileSeeders don't have an id\");\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/SliceSender.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE;\n\n/**\n * Responsible for sending a slice (1 MB or less) to a remote location.\n * It is sent by blocks of 8 KB (possibly less for the last one).\n */\nclass SliceSender\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SliceSender.class);\n\n\tprivate final FileTransferRsService fileTransferRsService;\n\tprivate final Location location;\n\tprivate final FileProvider provider;\n\tprivate final Sha1Sum hash;\n\tprivate final long totalSize;\n\tprivate long offset;\n\tprivate int size;\n\n\tpublic SliceSender(FileTransferRsService fileTransferRsService, Location location, FileProvider provider, Sha1Sum hash, long totalSize, long offset, int size)\n\t{\n\t\tthis.fileTransferRsService = fileTransferRsService;\n\t\tthis.location = location;\n\t\tthis.provider = provider;\n\t\tthis.hash = hash;\n\t\tthis.totalSize = totalSize;\n\t\tthis.offset = offset;\n\t\tthis.size = size;\n\t}\n\n\t/**\n\t * Sends data.\n\t *\n\t * @return false in case of an error or when it's done sending. Basically keep calling it when it's true\n\t */\n\tpublic boolean send()\n\t{\n\t\tvar length = Math.min(BLOCK_SIZE, size);\n\n\t\tbyte[] data;\n\t\ttry\n\t\t{\n\t\t\tdata = provider.read(offset, length);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to read file\", e);\n\t\t\treturn false;\n\t\t}\n\t\tif (data.length > 0)\n\t\t{\n\t\t\tfileTransferRsService.sendData(location, hash, totalSize, offset, data);\n\t\t}\n\n\t\tsize -= data.length;\n\t\toffset += data.length;\n\n\t\treturn size > 0 && data.length == length;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/doc-files/filetransfer.puml",
    "content": "@startuml\n'https://plantuml.com/component-diagram\n<style>\ndocument {\n  BackGroundColor #444444\n}\nroot {\n  FontColor #?black:white\n  LineColor white\n}\n</style>\nskinparam componentStyle rectangle\n\npackage \"File Transfer\" {\n  [FileTransferManager] --> [FileTransferRsService]\n}\n\n[FileTransferManager] <-> FileTransferAgents\n\npackage \"FileTransferAgents\" {\n\t[Leechers] <<FileProvider>>\n\t[Seeders] <<FileProvider>>\n}\n\n[Leechers] <--> [FileSystem]\n[Seeders] <--> [FileSystem]\n\n[FileSystem]\n\npackage \"Turtle\" {\n  [TurtleRsService] <-> Tunnels\n}\n\npackage \"Peers\" {\n  [Peer #1]\n  [Peer #2]\n}\n\ndatabase \"H2 Database\" {\n  folder \"Metadata\" {\n    [Files]\n  }\n}\n\n[FileTransferRsService] <--> [TurtleRsService]\n[FileTransferRsService] <--> [Metadata]\n[FileTransferRsService] <--> [Peers]\n[Tunnels] <-> [Peers]\n\n@enduml"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferChunkMapItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.List;\n\npublic class FileTransferChunkMapItem extends Item\n{\n\t@RsSerialized\n\tprivate boolean isClient;\n\n\t@RsSerialized\n\tprivate Sha1Sum hash;\n\n\t@RsSerialized\n\tprivate List<Integer> compressedChunks;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic FileTransferChunkMapItem()\n\t{\n\t}\n\n\tpublic FileTransferChunkMapItem(Sha1Sum hash, List<Integer> compressedChunks, boolean isClient)\n\t{\n\t\tthis.hash = hash;\n\t\tthis.compressedChunks = compressedChunks;\n\t\tthis.isClient = isClient;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.FILE_TRANSFER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 5;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic boolean isClient()\n\t{\n\t\treturn isClient;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic List<Integer> getCompressedChunks()\n\t{\n\t\treturn compressedChunks;\n\t}\n\n\t@Override\n\tpublic FileTransferChunkMapItem clone()\n\t{\n\t\treturn (FileTransferChunkMapItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileTransferChunkMapItem{\" +\n\t\t\t\t\"isClient=\" + isClient +\n\t\t\t\t\", hash=\" + hash +\n\t\t\t\t\", compressedChunks=\" + compressedChunks +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferChunkMapRequestItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class FileTransferChunkMapRequestItem extends Item\n{\n\t@RsSerialized\n\tprivate boolean isLeecher;\n\n\t@RsSerialized\n\tprivate Sha1Sum hash;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic FileTransferChunkMapRequestItem()\n\t{\n\t}\n\n\tpublic FileTransferChunkMapRequestItem(Sha1Sum hash, boolean isLeecher)\n\t{\n\t\tthis.hash = hash;\n\t\tthis.isLeecher = isLeecher;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.FILE_TRANSFER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 4;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic boolean isLeecher()\n\t{\n\t\treturn isLeecher;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\t@Override\n\tpublic FileTransferChunkMapRequestItem clone()\n\t{\n\t\treturn (FileTransferChunkMapRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileTransferChunkMapRequestItem{\" +\n\t\t\t\t\"isLeecher=\" + isLeecher +\n\t\t\t\t\", hash=\" + hash +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferDataItem.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.common.FileData;\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport static io.xeres.app.xrs.serialization.TlvType.FILE_DATA;\n\npublic class FileTransferDataItem extends Item\n{\n\t@RsSerialized(tlvType = FILE_DATA)\n\tprivate FileData fileData;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic FileTransferDataItem()\n\t{\n\t}\n\n\tpublic FileTransferDataItem(long offset, long size, Sha1Sum hash, byte[] data)\n\t{\n\t\tvar fileItem = new FileItem(size, hash, \"\", \"\", 0);\n\t\tfileData = new FileData(fileItem, offset, data);\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.FILE_TRANSFER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.NORMAL.getPriority();\n\t}\n\n\tpublic FileData getFileData()\n\t{\n\t\treturn fileData;\n\t}\n\n\t@Override\n\tpublic FileTransferDataItem clone()\n\t{\n\t\treturn (FileTransferDataItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileTransferDataItem{\" +\n\t\t\t\t\"fileData=\" + fileData +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferDataRequestItem.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.common.FileItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport static io.xeres.app.xrs.serialization.TlvType.FILE_ITEM;\n\npublic class FileTransferDataRequestItem extends Item\n{\n\t@RsSerialized\n\tprivate long fileOffset;\n\n\t@RsSerialized\n\tprivate int chunkSize;\n\n\t@RsSerialized(tlvType = FILE_ITEM)\n\tprivate FileItem fileItem;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic FileTransferDataRequestItem()\n\t{\n\t}\n\n\tpublic FileTransferDataRequestItem(long fileSize, Sha1Sum hash, long fileOffset, int chunkSize)\n\t{\n\t\tfileItem = new FileItem(fileSize, hash, null, null, 0);\n\n\t\tthis.fileOffset = fileOffset;\n\t\tthis.chunkSize = chunkSize;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.FILE_TRANSFER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic long getFileOffset()\n\t{\n\t\treturn fileOffset;\n\t}\n\n\tpublic int getChunkSize()\n\t{\n\t\treturn chunkSize;\n\t}\n\n\tpublic FileItem getFileItem()\n\t{\n\t\treturn fileItem;\n\t}\n\n\t@Override\n\tpublic FileTransferDataRequestItem clone()\n\t{\n\t\treturn (FileTransferDataRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileTransferDataRequestItem{\" +\n\t\t\t\t\"fileOffset=\" + fileOffset +\n\t\t\t\t\", chunkSize=\" + chunkSize +\n\t\t\t\t\", fileItem=\" + fileItem +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferSingleChunkCrcItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class FileTransferSingleChunkCrcItem extends Item\n{\n\t@RsSerialized\n\tprivate Sha1Sum hash;\n\n\t@RsSerialized\n\tprivate int chunkNumber;\n\n\t@RsSerialized\n\tprivate Sha1Sum checkSum;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic FileTransferSingleChunkCrcItem()\n\t{\n\t}\n\n\tpublic FileTransferSingleChunkCrcItem(Sha1Sum hash, int chunkNumber, Sha1Sum checkSum)\n\t{\n\t\tthis.hash = hash;\n\t\tthis.chunkNumber = chunkNumber;\n\t\tthis.checkSum = checkSum;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.FILE_TRANSFER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 9;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic int getChunkNumber()\n\t{\n\t\treturn chunkNumber;\n\t}\n\n\tpublic Sha1Sum getCheckSum()\n\t{\n\t\treturn checkSum;\n\t}\n\n\t@Override\n\tpublic FileTransferSingleChunkCrcItem clone()\n\t{\n\t\treturn (FileTransferSingleChunkCrcItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileTransferSingleChunkCrcItem{\" +\n\t\t\t\t\"hash=\" + hash +\n\t\t\t\t\", chunkNumber=\" + chunkNumber +\n\t\t\t\t\", checkSum=\" + checkSum +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/FileTransferSingleChunkCrcRequestItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class FileTransferSingleChunkCrcRequestItem extends Item\n{\n\t@RsSerialized\n\tprivate Sha1Sum hash;\n\n\t@RsSerialized\n\tprivate int chunkNumber;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic FileTransferSingleChunkCrcRequestItem()\n\t{\n\t}\n\n\tpublic FileTransferSingleChunkCrcRequestItem(Sha1Sum hash, int chunkNumber)\n\t{\n\t\tthis.hash = hash;\n\t\tthis.chunkNumber = chunkNumber;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.FILE_TRANSFER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 8;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic int getChunkNumber()\n\t{\n\t\treturn chunkNumber;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\t@Override\n\tpublic FileTransferSingleChunkCrcRequestItem clone()\n\t{\n\t\treturn (FileTransferSingleChunkCrcRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"FileTransferSingleChunkCrcRequestItem{\" +\n\t\t\t\t\"hash=\" + hash +\n\t\t\t\t\", chunkNumber=\" + chunkNumber +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleChunkCrcItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\nimport io.xeres.common.id.Sha1Sum;\n\nimport static io.xeres.app.xrs.service.turtle.item.TunnelDirection.CLIENT;\n\npublic class TurtleChunkCrcItem extends TurtleGenericTunnelItem\n{\n\t@RsSerialized\n\tprivate int chunkNumber;\n\n\t@RsSerialized\n\tprivate Sha1Sum checksum;\n\n\tpublic TurtleChunkCrcItem()\n\t{\n\t\tsetDirection(CLIENT);\n\t}\n\n\tpublic TurtleChunkCrcItem(int chunkNumber, Sha1Sum checksum)\n\t{\n\t\tsuper();\n\t\tthis.chunkNumber = chunkNumber;\n\t\tthis.checksum = checksum;\n\t}\n\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 20;\n\t}\n\n\tpublic int getChunkNumber()\n\t{\n\t\treturn chunkNumber;\n\t}\n\n\tpublic Sha1Sum getChecksum()\n\t{\n\t\treturn checksum;\n\t}\n\n\t@Override\n\tpublic TurtleChunkCrcItem clone()\n\t{\n\t\treturn (TurtleChunkCrcItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleChunkCrcItem{\" +\n\t\t\t\t\"chunkNumber=\" + chunkNumber +\n\t\t\t\t\", checksum=\" + checksum +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleChunkCrcRequestItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\n\nimport static io.xeres.app.xrs.service.turtle.item.TunnelDirection.SERVER;\n\npublic class TurtleChunkCrcRequestItem extends TurtleGenericTunnelItem\n{\n\t@RsSerialized\n\tprivate int chunkNumber;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleChunkCrcRequestItem()\n\t{\n\t\tsetDirection(SERVER);\n\t}\n\n\tpublic TurtleChunkCrcRequestItem(int chunkNumber)\n\t{\n\t\tsuper();\n\t\tthis.chunkNumber = chunkNumber;\n\t}\n\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 21;\n\t}\n\n\tpublic int getChunkNumber()\n\t{\n\t\treturn chunkNumber;\n\t}\n\n\t@Override\n\tpublic TurtleChunkCrcRequestItem clone()\n\t{\n\t\treturn (TurtleChunkCrcRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleChunkCrcRequestItem{\" +\n\t\t\t\t\"chunkNumber=\" + chunkNumber +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileDataItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\n\nimport java.util.Arrays;\n\nimport static io.xeres.app.xrs.service.turtle.item.TunnelDirection.CLIENT;\n\npublic class TurtleFileDataItem extends TurtleGenericTunnelItem\n{\n\t@RsSerialized\n\tprivate long chunkOffset;\n\n\t@RsSerialized\n\tprivate byte[] chunkData;\n\n\tpublic TurtleFileDataItem()\n\t{\n\t\tsetDirection(CLIENT);\n\t}\n\n\tpublic TurtleFileDataItem(long chunkOffset, byte[] chunkData)\n\t{\n\t\tthis();\n\t\tthis.chunkOffset = chunkOffset;\n\t\tthis.chunkData = chunkData;\n\t}\n\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 8;\n\t}\n\n\tpublic long getChunkOffset()\n\t{\n\t\treturn chunkOffset;\n\t}\n\n\tpublic byte[] getChunkData()\n\t{\n\t\treturn chunkData;\n\t}\n\n\t@Override\n\tpublic TurtleFileDataItem clone()\n\t{\n\t\treturn (TurtleFileDataItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileDataItem{\" +\n\t\t\t\t\"chunkOffset=\" + chunkOffset +\n\t\t\t\t\", chunkData=\" + Arrays.toString(chunkData) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileMapItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.turtle.item.TunnelDirection;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\n\nimport java.lang.reflect.ParameterizedType;\nimport java.lang.reflect.Type;\nimport java.util.List;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\n\npublic class TurtleFileMapItem extends TurtleGenericTunnelItem implements RsSerializable\n{\n\tprivate List<Integer> compressedChunks;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleFileMapItem()\n\t{\n\t}\n\n\tpublic TurtleFileMapItem(List<Integer> compressedChunks)\n\t{\n\t\tthis.compressedChunks = compressedChunks;\n\t}\n\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 16;\n\t}\n\n\t@Override\n\tpublic TurtleFileMapItem clone()\n\t{\n\t\treturn (TurtleFileMapItem) super.clone();\n\t}\n\n\tpublic List<Integer> getCompressedChunks()\n\t{\n\t\treturn compressedChunks;\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, getTunnelId());\n\t\tsize += serialize(buf, getDirection() == TunnelDirection.CLIENT ? 1 : 2);\n\t\t//noinspection unchecked\n\t\tsize += serialize(buf, (List<Object>) (List<?>) compressedChunks);\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tsetTunnelId(deserializeInt(buf));\n\t\tvar tunnelDirection = deserializeInt(buf);\n\t\tsetDirection(tunnelDirection == 1 ? TunnelDirection.CLIENT : TunnelDirection.SERVER);\n\t\t//noinspection unchecked\n\t\tcompressedChunks = (List<Integer>) (List<?>) deserializeList(buf, new ParameterizedType()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic Type[] getActualTypeArguments()\n\t\t\t{\n\t\t\t\treturn new Type[]{Integer.class};\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic Type getRawType()\n\t\t\t{\n\t\t\t\treturn List.class;\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic Type getOwnerType()\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileMapItem{\" +\n\t\t\t\t\"compressedChunks=\" + compressedChunks +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileMapRequestItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.turtle.item.TunnelDirection;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.deserializeInt;\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\n\npublic class TurtleFileMapRequestItem extends TurtleGenericTunnelItem implements RsSerializable\n{\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 17;\n\t}\n\n\t@Override\n\tpublic TurtleFileMapRequestItem clone()\n\t{\n\t\treturn (TurtleFileMapRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, getTunnelId());\n\t\tsize += serialize(buf, getDirection() == TunnelDirection.CLIENT ? 1 : 2);\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tsetTunnelId(deserializeInt(buf));\n\t\tsetDirection(deserializeInt(buf) == 1 ? TunnelDirection.CLIENT : TunnelDirection.SERVER);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileMapRequestItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/filetransfer/item/TurtleFileRequestItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\n\nimport static io.xeres.app.xrs.service.turtle.item.TunnelDirection.SERVER;\n\npublic class TurtleFileRequestItem extends TurtleGenericTunnelItem\n{\n\t@RsSerialized\n\tprivate long chunkOffset;\n\n\t@RsSerialized\n\tprivate int chunkSize;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleFileRequestItem()\n\t{\n\t\tsetDirection(SERVER);\n\t}\n\n\tpublic TurtleFileRequestItem(long chunkOffset, int chunkSize)\n\t{\n\t\tsuper();\n\t\tthis.chunkOffset = chunkOffset;\n\t\tthis.chunkSize = chunkSize;\n\t}\n\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 7;\n\t}\n\n\tpublic long getChunkOffset()\n\t{\n\t\treturn chunkOffset;\n\t}\n\n\tpublic int getChunkSize()\n\t{\n\t\treturn chunkSize;\n\t}\n\n\t@Override\n\tpublic TurtleFileRequestItem clone()\n\t{\n\t\treturn (TurtleFileRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileRequestItem{\" +\n\t\t\t\t\"chunkOffset=\" + chunkOffset +\n\t\t\t\t\", chunkSize=\" + chunkSize +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/forum/ForumRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.forum;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.forum.ForumMessageItemSummary;\nimport io.xeres.app.database.model.gxs.*;\nimport io.xeres.app.database.repository.GxsForumGroupRepository;\nimport io.xeres.app.database.repository.GxsForumMessageRepository;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.notification.forum.ForumNotificationService;\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.forum.item.ForumGroupItem;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.app.xrs.service.gxs.GxsAuthentication;\nimport io.xeres.app.xrs.service.gxs.GxsHelperService;\nimport io.xeres.app.xrs.service.gxs.GxsRsService;\nimport io.xeres.app.xrs.service.gxs.GxsTransactionManager;\nimport io.xeres.app.xrs.service.gxs.item.GxsSyncMessageRequestItem;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_AUTHOR;\nimport static io.xeres.common.protocol.xrs.RsServiceType.GXS_FORUMS;\n\n@Component\npublic class ForumRsService extends GxsRsService<ForumGroupItem, ForumMessageItem>\n{\n\tprivate static final Duration SYNCHRONIZATION_INITIAL_DELAY = Duration.ofSeconds(30);\n\tprivate static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1);\n\n\tprivate final GxsForumGroupRepository gxsForumGroupRepository;\n\tprivate final GxsForumMessageRepository gxsForumMessageRepository;\n\tprivate final GxsHelperService<ForumGroupItem, ForumMessageItem> gxsHelperService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final ForumNotificationService forumNotificationService;\n\n\tpublic ForumRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsForumGroupRepository gxsForumGroupRepository, GxsForumMessageRepository gxsForumMessageRepository, GxsHelperService<ForumGroupItem, ForumMessageItem> gxsHelperService, ForumNotificationService forumNotificationService)\n\t{\n\t\tsuper(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService);\n\t\tthis.gxsForumGroupRepository = gxsForumGroupRepository;\n\t\tthis.gxsForumMessageRepository = gxsForumMessageRepository;\n\t\tthis.gxsHelperService = gxsHelperService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.forumNotificationService = forumNotificationService;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn GXS_FORUMS;\n\t}\n\n\t@Override\n\tprotected GxsAuthentication getAuthentication()\n\t{\n\t\t// Anybody can write messages on forums\n\t\treturn new GxsAuthentication.Builder()\n\t\t\t\t.withRequirements(EnumSet.of(ROOT_NEEDS_AUTHOR, CHILD_NEEDS_AUTHOR))\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tsuper.initialize(peerConnection);\n\t\tpeerConnection.scheduleWithFixedDelay(\n\t\t\t\t() -> syncMessages(peerConnection),\n\t\t\t\tSYNCHRONIZATION_INITIAL_DELAY.toSeconds(),\n\t\t\t\tSYNCHRONIZATION_DELAY.toSeconds(),\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\t@Override\n\tpublic void syncMessages(PeerConnection peerConnection)\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\t// Request new messages for all subscribed groups\n\t\t\tfindAllSubscribedGroups().forEach(forumGroupItem -> {\n\t\t\t\tvar request = new GxsSyncMessageRequestItem(forumGroupItem.getGxsId(), gxsHelperService.getLastPeerMessagesUpdate(peerConnection.getLocation(), forumGroupItem.getGxsId(), getServiceType()), ChronoUnit.YEARS.getDuration());\n\t\t\t\tlog.debug(\"Asking {} for new messages in {} ({}) since {}, last updated: {}\",\n\t\t\t\t\t\tpeerConnection,\n\t\t\t\t\t\tforumGroupItem.getName(),\n\t\t\t\t\t\trequest.getGxsId(),\n\t\t\t\t\t\tlog.isDebugEnabled() ? Instant.ofEpochSecond(request.getLimit()) : null,\n\t\t\t\t\t\tlog.isDebugEnabled() ? Instant.ofEpochSecond(request.getLastUpdated()) : null);\n\t\t\t\tpeerConnectionManager.writeItem(peerConnection, request, this);\n\t\t\t});\n\t\t}\n\t}\n\n\tpublic void fixDuplicates()\n\t{\n\t\tfindAllSubscribedGroups().forEach(forumGroupItem -> {\n\t\t\tgxsHelperService.fixHiddenMessages(forumGroupItem.getGxsId(), Instant.now().minus(Duration.ofDays(360))); // XXX: make the date range smaller... and move it somewhere else, perhaps\n\t\t});\n\t}\n\n\t@Override\n\tprotected List<ForumGroupItem> onAvailableGroupListRequest(PeerConnection recipient)\n\t{\n\t\treturn findAllSubscribedGroups();\n\t}\n\n\t@Override\n\tprotected List<ForumGroupItem> onGroupListRequest(Set<GxsId> ids)\n\t{\n\t\treturn findAllGroups(ids);\n\t}\n\n\t@Override\n\tprotected Set<GxsId> onAvailableGroupListResponse(Map<GxsId, Instant> ids)\n\t{\n\t\t// We want new forums as well as updated ones\n\t\tvar existingMap = findAllGroups(ids.keySet()).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished));\n\n\t\tids.entrySet().removeIf(gxsIdInstantEntry -> {\n\t\t\tvar existing = existingMap.get(gxsIdInstantEntry.getKey());\n\t\t\treturn existing != null && !gxsIdInstantEntry.getValue().isAfter(existing);\n\t\t});\n\t\treturn ids.keySet();\n\t}\n\n\t@Override\n\tprotected boolean onGroupReceived(ForumGroupItem item)\n\t{\n\t\tlog.debug(\"Received {}, saving/updating...\", item);\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onGroupsSaved(List<ForumGroupItem> items)\n\t{\n\t\tforumNotificationService.addOrUpdateGroups(items);\n\t}\n\n\t@Override\n\tprotected List<ForumMessageItem> onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since)\n\t{\n\t\treturn findAllMessagesInGroupSince(gxsId, since); // Don't return old messages, they're unimportant\n\t}\n\n\t@Override\n\tprotected List<? extends GxsMessageItem> onMessageListRequest(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn findAllMessagesIncludingOlds(gxsId, msgIds);\n\t}\n\n\t@Override\n\tprotected List<MsgId> onMessageListResponse(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\tvar existing = findAllMessagesIncludingOlds(gxsId, msgIds).stream()\n\t\t\t\t.map(GxsMessageItem::getMsgId)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tmsgIds.removeAll(existing);\n\n\t\treturn msgIds.stream().toList();\n\t}\n\n\t@Override\n\tprotected boolean onMessageReceived(ForumMessageItem item)\n\t{\n\t\tlog.debug(\"Received message {}, saving...\", item);\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onMessagesSaved(List<ForumMessageItem> items)\n\t{\n\t\tforumNotificationService.addOrUpdateMessages(items);\n\t}\n\n\t@Override\n\tprotected boolean onCommentReceived(CommentMessageItem item)\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tprotected void onCommentsSaved(List<CommentMessageItem> items)\n\t{\n\t\t// Nothing to do\n\t}\n\n\t@Override\n\tprotected boolean onVoteReceived(VoteMessageItem item)\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tprotected void onVotesSaved(List<VoteMessageItem> items)\n\t{\n\t\t// Nothing to do\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tsuper.handleItem(sender, item); // This is required for the @Transactional to work\n\t}\n\n\t@Transactional\n\tpublic void subscribeToForumGroup(long id)\n\t{\n\t\tvar forumGroupItem = findById(id).orElseThrow();\n\t\tforumGroupItem.setSubscribed(true);\n\t\tgxsHelperService.setLastServiceGroupsUpdateNow(GXS_FORUMS);\n\t\t// We don't need to send a sync notification here because it's not urgent.\n\t\t// The peers will poll normally to show if there's a new group available.\n\t}\n\n\t@Transactional\n\tpublic void unsubscribeFromForumGroup(long id)\n\t{\n\t\tvar forumGroupItem = findById(id).orElseThrow();\n\t\tforumGroupItem.setSubscribed(false);\n\t}\n\n\tpublic Optional<ForumGroupItem> findById(long id)\n\t{\n\t\treturn gxsForumGroupRepository.findById(id);\n\t}\n\n\tpublic List<ForumGroupItem> findAllGroups()\n\t{\n\t\treturn gxsForumGroupRepository.findAll();\n\t}\n\n\tpublic List<ForumGroupItem> findAllSubscribedGroups()\n\t{\n\t\treturn gxsForumGroupRepository.findAllBySubscribedIsTrue();\n\t}\n\n\tpublic List<ForumGroupItem> findAllGroups(Set<GxsId> gxsIds)\n\t{\n\t\treturn gxsForumGroupRepository.findAllByGxsIdIn(gxsIds);\n\t}\n\n\tpublic List<ForumMessageItem> findAllMessagesInGroupSince(GxsId gxsId, Instant since)\n\t{\n\t\treturn gxsForumMessageRepository.findAllByGxsIdAndPublishedAfterAndHiddenFalse(gxsId, since);\n\t}\n\n\tpublic List<ForumMessageItem> findAllMessages(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn gxsForumMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(gxsId, msgIds);\n\t}\n\n\tpublic List<ForumMessageItem> findAllMessagesIncludingOlds(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn gxsForumMessageRepository.findAllByGxsIdAndMsgIdIn(gxsId, msgIds);\n\t}\n\n\tpublic List<ForumMessageItem> findAllMessages(long groupId, Set<MsgId> msgIds)\n\t{\n\t\tvar forumGroup = gxsForumGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsForumMessageRepository.findAllByGxsIdAndMsgIdInAndHiddenFalse(forumGroup.getGxsId(), msgIds);\n\t}\n\n\t/**\n\t * Finds all messages. Prefer the other variants as this one is slower.\n\t *\n\t * @param msgIds the list of message ids\n\t * @return the messages\n\t */\n\tpublic List<ForumMessageItem> findAllMessages(Set<MsgId> msgIds)\n\t{\n\t\treturn gxsForumMessageRepository.findAllByMsgIdInAndHiddenFalse(msgIds);\n\t}\n\n\tpublic List<ForumMessageItem> findAllOldMessages(Set<MsgId> msgIds)\n\t{\n\t\treturn gxsForumMessageRepository.findAllByMsgIdInAndHiddenTrue(msgIds);\n\t}\n\n\tpublic int getUnreadCount(long groupId)\n\t{\n\t\tvar forumGroupItem = gxsForumGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsForumMessageRepository.countUnreadMessages(forumGroupItem.getGxsId());\n\t}\n\n\t@Transactional\n\tpublic Page<ForumMessageItemSummary> findAllMessagesSummary(long groupId, Pageable pageable)\n\t{\n\t\tvar forumGroup = gxsForumGroupRepository.findById(groupId).orElseThrow();\n\t\treturn gxsForumMessageRepository.findSummaryAllByGxsIdAndHiddenFalse(forumGroup.getGxsId(), pageable);\n\t}\n\n\tpublic ForumMessageItem findMessageById(long id)\n\t{\n\t\treturn gxsForumMessageRepository.findById(id).orElseThrow();\n\t}\n\n\tprivate ForumMessageItem saveMessage(MessageBuilder messageBuilder)\n\t{\n\t\tvar forumMessageItem = messageBuilder.build();\n\n\t\tforumMessageItem.setId(gxsForumMessageRepository.findByGxsIdAndMsgId(forumMessageItem.getGxsId(), forumMessageItem.getMsgId()).orElse(forumMessageItem).getId()); // XXX: not sure we should be able to overwrite a message. in which case is it correct? maybe throw?\n\t\tvar savedMessage = gxsForumMessageRepository.save(forumMessageItem);\n\t\tmarkOriginalMessageAsHidden(List.of(savedMessage));\n\t\tvar forumGroupItem = gxsForumGroupRepository.findByGxsId(forumMessageItem.getGxsId()).orElseThrow();\n\t\tforumGroupItem.setLastUpdated(Instant.now());\n\t\tgxsForumGroupRepository.save(forumGroupItem);\n\t\treturn savedMessage;\n\t}\n\n\t@Transactional\n\tpublic long createForumGroup(GxsId identity, String name, String description)\n\t{\n\t\tvar forumGroupItem = createGroup(name, false);\n\t\tforumGroupItem.setDescription(description);\n\n\t\tif (identity != null)\n\t\t{\n\t\t\tforumGroupItem.setAuthorGxsId(identity);\n\t\t}\n\n\t\tforumGroupItem.setCircleType(GxsCircleType.PUBLIC); // XXX: I think...\n\t\tforumGroupItem.setSignatureFlags(Set.of(GxsSignatureFlags.NONE_REQUIRED, GxsSignatureFlags.AUTHENTICATION_REQUIRED));\n\t\tforumGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC));\n\n\t\t// XXX: set list of moderators\n\n\t\tforumGroupItem.setSubscribed(true);\n\n\t\tforumGroupItem = saveForum(forumGroupItem);\n\n\t\tforumNotificationService.addOrUpdateGroups(List.of(forumGroupItem));\n\n\t\treturn forumGroupItem.getId();\n\t}\n\n\t@Transactional\n\tpublic void updateForumGroup(long groupId, String name, String description)\n\t{\n\t\tvar forumGroupItem = gxsForumGroupRepository.findById(groupId).orElseThrow();\n\t\tforumGroupItem.setName(name);\n\t\tforumGroupItem.setDescription(description);\n\n\t\tforumGroupItem = saveForum(forumGroupItem);\n\t\tforumNotificationService.addOrUpdateGroups(List.of(forumGroupItem));\n\t}\n\n\tprivate ForumGroupItem saveForum(ForumGroupItem forumGroupItem)\n\t{\n\t\tsignGroupIfNeeded(forumGroupItem);\n\t\tvar savedForum = gxsForumGroupRepository.save(forumGroupItem);\n\t\tgxsHelperService.setLastServiceGroupsUpdateNow(GXS_FORUMS);\n\t\tpeerConnectionManager.doForAllPeers(this::sendSyncNotification, this);\n\t\treturn savedForum;\n\t}\n\n\t@Transactional\n\tpublic long createForumMessage(IdentityGroupItem author, long forumId, String title, String content, long parentId, long originalId)\n\t{\n\t\t// XXX: check the size, like createBoardMessage()\n\t\tvar group = gxsForumGroupRepository.findById(forumId).orElseThrow();\n\n\t\tvar builder = new MessageBuilder(group, author, title);\n\n\t\tif (parentId != 0L)\n\t\t{\n\t\t\tbuilder.parentMsgId(gxsForumMessageRepository.findById(parentId).orElseThrow().getMsgId());\n\t\t}\n\n\t\tif (originalId != 0L)\n\t\t{\n\t\t\tbuilder.originalMsgId(gxsForumMessageRepository.findById(originalId).orElseThrow().getMsgId());\n\t\t}\n\n\t\tbuilder.getMessageItem().setContent(content);\n\n\t\tvar forumMessageItem = saveMessage(builder);\n\n\t\tforumNotificationService.addOrUpdateMessages(List.of(forumMessageItem));\n\n\t\tpeerConnectionManager.doForAllPeers(this::sendSyncNotification, this);\n\n\t\treturn forumMessageItem.getId();\n\t}\n\n\t@Transactional\n\tpublic void setMessageReadState(long messageId, boolean read)\n\t{\n\t\tvar message = gxsForumMessageRepository.findById(messageId).orElseThrow();\n\t\tmessage.setRead(read);\n\t\tvar group = gxsForumGroupRepository.findByGxsId(message.getGxsId()).orElseThrow();\n\t\tforumNotificationService.setMessageReadState(group.getId(), message.getId(), read);\n\t}\n\n\t@Transactional\n\tpublic void setAllGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tvar group = gxsForumGroupRepository.findById(groupId).orElseThrow();\n\t\tgxsForumMessageRepository.setAllGroupMessagesReadState(group.getGxsId(), read);\n\t\tforumNotificationService.setGroupMessagesReadState(groupId, read);\n\t}\n\n\t@Override\n\tpublic void shutdown()\n\t{\n\t\tforumNotificationService.shutdown();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/forum/item/ForumGroupItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.forum.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport jakarta.persistence.*;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\nimport static io.xeres.app.xrs.serialization.TlvType.*;\n\n@Entity(name = \"forum_group\")\npublic class ForumGroupItem extends GxsGroupItem\n{\n\tprivate String description;\n\n\t@ElementCollection\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"admin\"))\n\tprivate Set<GxsId> admins = new HashSet<>();\n\n\t@ElementCollection\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"pinned_post\"))\n\tprivate Set<MsgId> pinnedPosts = new HashSet<>();\n\n\t@Transient\n\tprivate boolean oldVersion; // Needed because RS added admins and pinnedPosts later, and it would break signature verification otherwise\n\n\tpublic ForumGroupItem()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic ForumGroupItem(GxsId gxsId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n\n\tpublic void setDescription(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, STR_DESCR, description);\n\t\tif (!oldVersion)\n\t\t{\n\t\t\tsize += serialize(buf, SET_GXS_ID, admins);\n\t\t\tsize += serialize(buf, SET_GXS_MSG_ID, pinnedPosts);\n\t\t}\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tdescription = (String) Serializer.deserialize(buf, STR_DESCR);\n\n\t\tif (buf.isReadable())\n\t\t{\n\t\t\t//noinspection unchecked\n\t\t\tadmins = (Set<GxsId>) Serializer.deserialize(buf, SET_GXS_ID);\n\t\t\t//noinspection unchecked\n\t\t\tpinnedPosts = (Set<MsgId>) Serializer.deserialize(buf, SET_GXS_MSG_ID);\n\t\t}\n\t\telse\n\t\t{\n\t\t\toldVersion = true;\n\t\t}\n\t}\n\n\t@Override\n\tpublic ForumGroupItem clone()\n\t{\n\t\treturn (ForumGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ForumGroupItem{\" +\n\t\t\t\tsuper.toString() +\n\t\t\t\t\", admins=\" + admins +\n\t\t\t\t\", pinnedPosts=\" + pinnedPosts +\n\t\t\t\t\", oldVersion=\" + oldVersion +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/forum/item/ForumMessageItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.forum.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Transient;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.serialize;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_MSG;\n\n@Entity(name = \"forum_message\")\npublic class ForumMessageItem extends GxsMessageItem\n{\n\t@Transient\n\tpublic static final ForumMessageItem EMPTY = new ForumMessageItem();\n\n\tprivate String content;\n\tprivate boolean read;\n\n\tpublic ForumMessageItem()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic ForumMessageItem(GxsId gxsId, MsgId msgId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetMsgId(msgId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\tpublic void setRead(boolean read)\n\t{\n\t\tthis.read = read;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\treturn serialize(buf, STR_MSG, content);\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tcontent = (String) Serializer.deserialize(buf, STR_MSG);\n\t}\n\n\t@Override\n\tpublic ForumMessageItem clone()\n\t{\n\t\treturn (ForumMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ForumMessageItem{\" +\n\t\t\t\tsuper.toString() +\n\t\t\t\t\", read=\" + read +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/GxsAuthentication.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport java.util.Set;\n\npublic final class GxsAuthentication\n{\n\tpublic enum Flags\n\t{\n\t\t/**\n\t\t * New threads need to be signed by the author of the message. Typical use: forums, since posts are signed.\n\t\t */\n\t\tROOT_NEEDS_AUTHOR,\n\t\t/**\n\t\t * All child messages/votes/comments need to be signed by the author of the message. Typical use: forums since response to posts are signed, and signed comments in channels.\n\t\t */\n\t\tCHILD_NEEDS_AUTHOR,\n\t\t/**\n\t\t * New threads need to be signed by the publish key of the group. Typical use: posts in channels. Only the creator of the group can post.\n\t\t */\n\t\tROOT_NEEDS_PUBLISH,\n\t\t/**\n\t\t * All messages/votes/comments need to be signed by the publish key of the group.\n\t\t */\n\t\tCHILD_NEEDS_PUBLISH\n\t}\n\n\tprivate final Set<Flags> requirements;\n\tprivate final boolean authorSigningGroups;\n\n\tprivate GxsAuthentication(Builder builder)\n\t{\n\t\trequirements = builder.requirements;\n\t\tauthorSigningGroups = builder.authorSigningGroups;\n\t}\n\n\tpublic Set<Flags> getRequirements()\n\t{\n\t\treturn requirements;\n\t}\n\n\tpublic boolean isAuthorSigningGroups()\n\t{\n\t\treturn authorSigningGroups;\n\t}\n\n\tpublic static final class Builder\n\t{\n\t\tprivate Set<Flags> requirements;\n\t\tprivate boolean authorSigningGroups;\n\n\t\tpublic Builder()\n\t\t{\n\t\t\t// Default constructor\n\t\t}\n\n\t\tpublic Builder withRequirements(Set<Flags> val)\n\t\t{\n\t\t\trequirements = val;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder withAuthorSigningGroups(boolean val)\n\t\t{\n\t\t\tauthorSigningGroups = val;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic GxsAuthentication build()\n\t\t{\n\t\t\treturn new GxsAuthentication(this);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/GxsHelperService.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport io.xeres.app.database.model.gxs.GxsClientUpdate;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.database.model.gxs.GxsServiceSetting;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.repository.GxsClientUpdateRepository;\nimport io.xeres.app.database.repository.GxsGroupItemRepository;\nimport io.xeres.app.database.repository.GxsMessageItemRepository;\nimport io.xeres.app.database.repository.GxsServiceSettingRepository;\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.app.xrs.service.gxs.item.GxsSyncGroupStatsItem;\nimport io.xeres.app.xrs.service.gxs.item.RequestType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.springframework.data.domain.Limit;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.function.Predicate;\nimport java.util.stream.Collectors;\n\n/**\n * Helper service to manage various GXS group and message functions.\n */\n@Service\npublic class GxsHelperService<G extends GxsGroupItem, M extends GxsMessageItem>\n{\n\tprivate final GxsClientUpdateRepository gxsClientUpdateRepository;\n\tprivate final GxsServiceSettingRepository gxsServiceSettingRepository;\n\tprivate final GxsGroupItemRepository gxsGroupItemRepository;\n\tprivate final GxsMessageItemRepository gxsMessageItemRepository;\n\n\tpublic GxsHelperService(GxsClientUpdateRepository gxsClientUpdateRepository, GxsServiceSettingRepository gxsServiceSettingRepository, GxsGroupItemRepository gxsGroupItemRepository, GxsMessageItemRepository gxsMessageItemRepository)\n\t{\n\t\tthis.gxsClientUpdateRepository = gxsClientUpdateRepository;\n\t\tthis.gxsServiceSettingRepository = gxsServiceSettingRepository;\n\t\tthis.gxsGroupItemRepository = gxsGroupItemRepository;\n\t\tthis.gxsMessageItemRepository = gxsMessageItemRepository;\n\t}\n\n\t/**\n\t * Gets the last update time of the peer's groups. The peer's time is always used, not our local time.\n\t *\n\t * @param location    the peer's location\n\t * @param serviceType the service type\n\t * @return the time when the peer last updated its groups, in peer's time\n\t */\n\tpublic Instant getLastPeerGroupsUpdate(Location location, RsServiceType serviceType)\n\t{\n\t\treturn gxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType())\n\t\t\t\t.map(GxsClientUpdate::getLastSynced)\n\t\t\t\t.orElse(Instant.EPOCH).truncatedTo(ChronoUnit.SECONDS);\n\t}\n\n\t/**\n\t * Gets the last update of the peer's group messages. The peer's time is always used, not our local time.\n\t *\n\t * @param location    the peer's location.\n\t * @param gxsId     the group's gxs id.\n\t * @param serviceType the service type.\n\t * @return the time when the peer last updated its group messages, in peer's time\n\t */\n\tpublic Instant getLastPeerMessagesUpdate(Location location, GxsId gxsId, RsServiceType serviceType)\n\t{\n\t\treturn gxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType())\n\t\t\t\t.map(gxsClientUpdate -> gxsClientUpdate.getMessageUpdate(gxsId))\n\t\t\t\t.orElse(Instant.EPOCH).truncatedTo(ChronoUnit.SECONDS);\n\t}\n\n\t/**\n\t * Sets the last update time of the peer's groups. The peer's time is always used, not our local time.\n\t *\n\t * @param location    the peer's location\n\t * @param update      the peer's last update time, in peer's time (so given by the peer itself). Never supply a time computed locally\n\t * @param serviceType the service type\n\t */\n\t@Transactional\n\tpublic void setLastPeerGroupsUpdate(Location location, Instant update, RsServiceType serviceType)\n\t{\n\t\tgxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType())\n\t\t\t\t.ifPresentOrElse(gxsClientUpdate -> gxsClientUpdate.setLastSynced(update), () -> gxsClientUpdateRepository.save(new GxsClientUpdate(location, serviceType.getType(), update)));\n\t}\n\n\t/**\n\t * Sets the last update time of a peer's messages. The peer's time is always used, not our local time.\n\t *\n\t * @param location    the peer's location\n\t * @param gxsId       the group\n\t * @param update      the peer's last update time, in peer's time (so given by the peer itself). Never supply a time computed locally.\n\t * @param serviceType the service type\n\t */\n\t@Transactional\n\tpublic void setLastPeerMessageUpdate(Location location, GxsId gxsId, Instant update, RsServiceType serviceType)\n\t{\n\t\tgxsClientUpdateRepository.findByLocationAndServiceType(location, serviceType.getType())\n\t\t\t\t.ifPresentOrElse(gxsClientUpdate -> gxsClientUpdate.putMessageUpdate(gxsId, update), () -> {\n\t\t\t\t\tvar clientUpdate = new GxsClientUpdate(location, serviceType.getType(), Instant.EPOCH);\n\t\t\t\t\tclientUpdate.putMessageUpdate(gxsId, update);\n\t\t\t\t\tgxsClientUpdateRepository.save(clientUpdate);\n\t\t\t\t});\n\t}\n\n\t/**\n\t * Gets the last time our service's groups were updated. This uses the local time.\n\t *\n\t * @param serviceType the service type\n\t * @return the last time\n\t */\n\tpublic Instant getLastServiceGroupsUpdate(RsServiceType serviceType)\n\t{\n\t\treturn gxsServiceSettingRepository.findById(serviceType.getType())\n\t\t\t\t.map(GxsServiceSetting::getLastUpdated)\n\t\t\t\t.orElse(Instant.EPOCH).truncatedTo(ChronoUnit.SECONDS);\n\t}\n\n\t/**\n\t * Sets the service group's last update to now.\n\t *\n\t * @param serviceType the service type\n\t */\n\t@Transactional\n\tpublic void setLastServiceGroupsUpdateNow(RsServiceType serviceType)\n\t{\n\t\tvar now = Instant.now(); // we always use local time\n\t\tgxsServiceSettingRepository.findById(serviceType.getType())\n\t\t\t\t.ifPresentOrElse(gxsServiceSetting -> gxsServiceSetting.setLastUpdated(now), () -> gxsServiceSettingRepository.save(new GxsServiceSetting(serviceType.getType(), now)));\n\t}\n\n\t/**\n\t * Saves an external group.\n\t *\n\t * @param gxsGroupItem the group\n\t * @param confirmation the confirmation predicate\n\t * @return the group\n\t */\n\t@Transactional\n\tpublic Optional<G> saveGroup(G gxsGroupItem, Predicate<G> confirmation)\n\t{\n\t\tgxsGroupItem.setId(gxsGroupItemRepository.findByGxsId(gxsGroupItem.getGxsId()).orElse(gxsGroupItem).getId());\n\t\tif (confirmation.test(gxsGroupItem) && gxsGroupItem.isExternal()) // Don't overwrite our own groups\n\t\t{\n\t\t\treturn Optional.of(gxsGroupItemRepository.save(gxsGroupItem));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t/**\n\t * Gets a group.\n\t *\n\t * @param gxsId the gxsId of the group\n\t * @return the group, null if it doesn't exist\n\t */\n\tpublic GxsGroupItem getGroup(GxsId gxsId)\n\t{\n\t\treturn gxsGroupItemRepository.findByGxsId(gxsId).orElse(null);\n\t}\n\n\t@Transactional(readOnly = true)\n\tpublic Optional<GxsSyncGroupStatsItem> findGroupStatsByGxsId(GxsId gxsId)\n\t{\n\t\treturn gxsGroupItemRepository.findByGxsIdAndSubscribedIsTrue(gxsId)\n\t\t\t\t.map(group -> {\n\t\t\t\t\tvar numberOfPosts = gxsMessageItemRepository.countByGxsId(group.getGxsId());\n\t\t\t\t\treturn new GxsSyncGroupStatsItem(RequestType.RESPONSE, group.getGxsId(), group.getLastUpdated() != null ? (int) group.getLastUpdated().getEpochSecond() : 0, numberOfPosts);\n\t\t\t\t});\n\t}\n\n\t@Transactional\n\tpublic void updateGroupStats(GxsSyncGroupStatsItem item)\n\t{\n\t\tgxsGroupItemRepository.findByGxsId(item.getGxsId()).ifPresent(group -> {\n\t\t\tgroup.setVisibleMessageCount(Math.max(group.getVisibleMessageCount(), item.getNumberOfPosts()));\n\t\t\tif (item.getLastPostTimestamp() > group.getLastActivity().getEpochSecond())\n\t\t\t{\n\t\t\t\tgroup.setLastActivity(Instant.ofEpochSecond(item.getLastPostTimestamp()));\n\t\t\t}\n\t\t\t// XXX: how to set popularity?\n\t\t});\n\t}\n\n\t@Transactional\n\tpublic Set<GxsId> findGroupsToRequestStats(Instant now, Duration delay)\n\t{\n\t\treturn gxsGroupItemRepository.findByOrderByLastStatistics(Limit.of(2)).stream()\n\t\t\t\t.filter(gxsGroupItem -> Duration.between(gxsGroupItem.getLastStatistics(), now).compareTo(delay) > 0)\n\t\t\t\t.map(gxsGroupItem -> {\n\t\t\t\t\tgxsGroupItem.setLastStatistics(now);\n\t\t\t\t\treturn gxsGroupItem.getGxsId();\n\t\t\t\t})\n\t\t\t\t.collect(Collectors.toSet());\n\t}\n\n\t@Transactional\n\tpublic Optional<M> saveMessage(M gxsMessageItem, Predicate<M> confirmation)\n\t{\n\t\tgxsMessageItem.setId(gxsMessageItemRepository.findByGxsIdAndMsgId(gxsMessageItem.getGxsId(), gxsMessageItem.getMsgId()).orElse(gxsMessageItem).getId());\n\t\tif (confirmation.test(gxsMessageItem) /*&& gxsMessageItem.isExternal()*/) // Don't overwrite our own messages (XXX: find a way to do the check)\n\t\t{\n\t\t\treturn Optional.of(gxsMessageItemRepository.save(gxsMessageItem));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tpublic void fixHiddenMessages(GxsId gxsId, Instant since)\n\t{\n\t\tgxsMessageItemRepository.fixIntervalDuplicates(gxsId, since);\n\t\tgxsMessageItemRepository.hideOldDuplicates(gxsId, since);\n\t}\n\n\t@Transactional\n\tpublic Optional<CommentMessageItem> saveComment(CommentMessageItem commentMessageItem, Predicate<CommentMessageItem> confirmation)\n\t{\n\t\tcommentMessageItem.setId(gxsMessageItemRepository.findByGxsIdAndMsgId(commentMessageItem.getGxsId(), commentMessageItem.getMsgId()).orElse(commentMessageItem).getId());\n\t\tif (confirmation.test(commentMessageItem) /*&& gxsMessageItem.isExternal()*/) // Don't overwrite our own messages (XXX: find a way to do the check)\n\t\t{\n\t\t\treturn Optional.of(gxsMessageItemRepository.save(commentMessageItem));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t@Transactional\n\tpublic Optional<VoteMessageItem> saveVote(VoteMessageItem voteMessageItem, Predicate<VoteMessageItem> confirmation)\n\t{\n\t\tvoteMessageItem.setId(gxsMessageItemRepository.findByGxsIdAndMsgId(voteMessageItem.getGxsId(), voteMessageItem.getMsgId()).orElse(voteMessageItem).getId());\n\t\tif (confirmation.test(voteMessageItem) /*&& gxsMessageItem.isExternal()*/) // Don't overwrite our own messages (XXX: find a way to do the check)\n\t\t{\n\t\t\treturn Optional.of(gxsMessageItemRepository.save(voteMessageItem));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\t/**\n\t * Overrides a message. This allows to \"edit\" a message. If the message is found, it's marked as hidden.\n\t *\n\t * @param gxsId     the group of the message\n\t * @param msgId the message id\n\t * @param authorGxsId  the author id\n\t */\n\t@Transactional\n\tpublic void overrideMessage(GxsId gxsId, MsgId msgId, GxsId authorGxsId)\n\t{\n\t\tgxsMessageItemRepository.findByGxsIdAndMsgId(gxsId, msgId).ifPresent(gxsMessageItem -> {\n\t\t\tif (Objects.equals(authorGxsId, gxsMessageItem.getAuthorGxsId()))\n\t\t\t{\n\t\t\t\tgxsMessageItem.setHidden(true);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Updates the last posted field of the group. This allows knowing when the last time a message was added in a group was.\n\t *\n\t * @param gxsId      the group\n\t * @param lastPosted the last posted value\n\t */\n\t@Transactional\n\tpublic void updateLastPosted(GxsId gxsId, Instant lastPosted)\n\t{\n\t\tgxsGroupItemRepository.findByGxsId(gxsId).ifPresent(gxsGroupItem -> {\n\t\t\tif (gxsGroupItem.getLastUpdated() == null || gxsGroupItem.getLastUpdated().isBefore(lastPosted))\n\t\t\t{\n\t\t\t\tgxsGroupItem.setLastUpdated(lastPosted);\n\t\t\t}\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/GxsRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.gxs.GxsCircleType;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemUtils;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.gxs.item.*;\nimport io.xeres.app.xrs.service.identity.IdentityManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.common.util.NoSuppressedRunnable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.ParameterizedType;\nimport java.security.KeyPair;\nimport java.security.PublicKey;\nimport java.security.interfaces.RSAPrivateKey;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.*;\n\nimport static io.xeres.app.net.peer.PeerConnection.KEY_GXS_TRANSACTION_ID;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.*;\nimport static io.xeres.app.xrs.service.gxs.item.GxsSyncGroupItem.REQUEST;\nimport static io.xeres.app.xrs.service.gxs.item.GxsSyncGroupItem.RESPONSE;\nimport static java.util.stream.Collectors.toMap;\nimport static java.util.stream.Collectors.toSet;\nimport static org.apache.commons.collections4.CollectionUtils.isEmpty;\n\n/**\n * This abstract class is used by all Gxs services. The transfer system goes the following way, for example\n * if Juergen asks Heike every minute if she has new groups for him:\n * <p>\n * <img src=\"doc-files/transfer.png\" alt=\"Transfer diagram\">\n *\n * @param <G> the GxsGroupItem subclass\n * @param <M> the GxsMessageItem subclass\n */\npublic abstract class GxsRsService<G extends GxsGroupItem, M extends GxsMessageItem> extends RsService\n{\n\tprotected final Logger log = LoggerFactory.getLogger(getClass().getName());\n\n\tprivate static final int GXS_KEY_SIZE = 2048; // The RSA size of Gxs keys. Do not change unless you want everything to break.\n\tprivate static final int KEY_LAST_SYNC_REQUEST = 1;\n\n\t/**\n\t * When to perform synchronization run with a peer.\n\t */\n\tprivate static final Duration SYNCHRONIZATION_DELAY_INITIAL_MIN = Duration.ofSeconds(10);\n\tprivate static final Duration SYNCHRONIZATION_DELAY_INITIAL_MAX = Duration.ofSeconds(15);\n\tprivate static final Duration SYNCHRONIZATION_DELAY = Duration.ofMinutes(1);\n\n\tprivate static final int MESSAGES_PER_TRANSACTIONS = 20;\n\n\tprivate static final Duration PENDING_VERIFICATION_MAX = Duration.ofMinutes(1);\n\tprivate static final Duration PENDING_VERIFICATION_DELAY = Duration.ofSeconds(10);\n\n\tprivate static final Duration GROUP_STATISTICS_DELAY = Duration.ofMinutes(2);\n\n\tprivate Instant lastGroupStatistics = Instant.EPOCH;\n\n\tprotected final GxsTransactionManager gxsTransactionManager;\n\tprotected final PeerConnectionManager peerConnectionManager;\n\tprivate final IdentityManager identityManager;\n\tprivate final GxsHelperService<G, M> gxsHelperService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\n\tprivate final Class<G> itemGroupClass;\n\tprivate final Class<M> itemMessageClass;\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate final Map<G, Long> pendingGxsGroups = new ConcurrentHashMap<>();\n\tprivate final Map<GxsMessageItem, Long> pendingGxsMessages = new ConcurrentHashMap<>();\n\n\tprivate final Set<GxsId> ongoingGxsMessageTransfers = ConcurrentHashMap.newKeySet();\n\n\tprivate final GxsAuthentication gxsAuthentication;\n\n\t@SuppressWarnings(\"unchecked\")\n\tprotected GxsRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityManager identityManager, GxsHelperService<G, M> gxsHelperService)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.gxsTransactionManager = gxsTransactionManager;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.identityManager = identityManager;\n\t\tthis.gxsHelperService = gxsHelperService;\n\n\t\t// Type information is available when subclassing a class using a generic type, which means itemClass is the class of G\n\t\titemGroupClass = (Class<G>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0];\n\t\titemMessageClass = (Class<M>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[1]; // and M\n\n\t\tgxsAuthentication = Objects.requireNonNull(getAuthentication(), \"Authentication cannot be null\");\n\t}\n\n\tprotected enum VerificationStatus\n\t{\n\t\tOK,\n\t\tFAILED,\n\t\tDELAYED\n\t}\n\n\t/**\n\t * Called when the peer wants a list of our subscribed groups.\n\t *\n\t * @param recipient the recipient of the result\n\t * @return the available groups that we have\n\t */\n\tprotected abstract List<G> onAvailableGroupListRequest(PeerConnection recipient);\n\n\t/**\n\t * Called when a peer sends the list of new or updated groups that might interest us.\n\t *\n\t * @param ids the ids of updated groups and their update time that the peer has for us\n\t * @return the subset of those groups that we actually want\n\t */\n\tprotected abstract Set<GxsId> onAvailableGroupListResponse(Map<GxsId, Instant> ids);\n\n\t/**\n\t * Called when the peer wants specific groups to be transferred to him.\n\t *\n\t * @param ids the groups that the peer wants\n\t * @return the groups that we have available within the requested set\n\t */\n\tprotected abstract List<G> onGroupListRequest(Set<GxsId> ids);\n\n\t/**\n\t * Called when a group has been received.\n\t *\n\t * @param item the received group\n\t * @return true if the group must be saved to disk\n\t */\n\tprotected abstract boolean onGroupReceived(G item);\n\n\t/**\n\t * Called when the groups have been saved.\n\t *\n\t * @param items the list of groups that have been successfully saved to disk\n\t */\n\tprotected abstract void onGroupsSaved(List<G> items);\n\n\t/**\n\t * Called when the peer wants a list of new messages within a group that we have for him.\n\t *\n\t * @param recipient the recipient of the result\n\t * @param gxsId   the group gxs ID\n\t * @param since     the time after which the messages are relevant. Everything before is ignored\n\t * @return the available messages that we have\n\t */\n\tprotected abstract List<M> onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since);\n\n\t/**\n\t * Called when the peer wants specific messages to be transferred to him, within a group.\n\t *\n\t * @param gxsId    the group gxs ID\n\t * @param msgIds the ids of messages that the peer wants\n\t * @return the messages that we have available within the requested set\n\t */\n\tprotected abstract List<? extends GxsMessageItem> onMessageListRequest(GxsId gxsId, Set<MsgId> msgIds);\n\n\t/**\n\t * Called when a peer sends the list of new messages that might interest us, within a group.\n\t *\n\t * @param gxsId    the group gxs ID\n\t * @param msgIds the ids of new messages\n\t * @return the subset of those messages that we actually want\n\t */\n\tprotected abstract List<MsgId> onMessageListResponse(GxsId gxsId, Set<MsgId> msgIds);\n\n\t/**\n\t * Called when a message has been received.\n\t *\n\t * @param item the received message\n\t * @return true if we want to save it\n\t */\n\tprotected abstract boolean onMessageReceived(M item);\n\n\t/**\n\t * Called when the messages have been saved.\n\t *\n\t * @param items the list of saved messages\n\t */\n\tprotected abstract void onMessagesSaved(List<M> items);\n\n\t/**\n\t * Called when a comment has been received.\n\t *\n\t * @param item the received comment\n\t * @return true if we want to save it\n\t */\n\tprotected abstract boolean onCommentReceived(CommentMessageItem item);\n\n\t/**\n\t * Called when the comments have been saved.\n\t *\n\t * @param items the list of saved comments\n\t */\n\tprotected abstract void onCommentsSaved(List<CommentMessageItem> items);\n\n\t/**\n\t * Called when a vote has been received.\n\t *\n\t * @param item the received vote\n\t * @return true if we want to save it\n\t */\n\tprotected abstract boolean onVoteReceived(VoteMessageItem item);\n\n\t/**\n\t * Called when the votes have been saved.\n\t *\n\t * @param items the list of saved votes\n\t */\n\tprotected abstract void onVotesSaved(List<VoteMessageItem> items);\n\n\t/**\n\t * Called to gather the authentication requirements for the service.\n\t *\n\t * @return the authentication requirements\n\t */\n\tprotected abstract GxsAuthentication getAuthentication();\n\n\t/**\n\t * Called periodically (normally each minute, or when receiving a {@link GxsSyncNotifyItem}) to sync messages.\n\t *\n\t * @param recipient the peer to sync messages with\n\t */\n\tprotected abstract void syncMessages(PeerConnection recipient);\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\tthrow new IllegalStateException(\"Must override getServiceType()\");\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.LOW;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::manageAll,\n\t\t\t\tgetInitPriority().getMaxTime() + PENDING_VERIFICATION_DELAY.toSeconds() / 2,\n\t\t\t\tPENDING_VERIFICATION_DELAY.toSeconds());\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.scheduleWithFixedDelay(\n\t\t\t\t() -> autoSync(peerConnection),\n\t\t\t\tThreadLocalRandom.current().nextLong(SYNCHRONIZATION_DELAY_INITIAL_MIN.toSeconds(), SYNCHRONIZATION_DELAY_INITIAL_MAX.toSeconds() + 1),\n\t\t\t\tSYNCHRONIZATION_DELAY.toSeconds(),\n\t\t\t\tTimeUnit.SECONDS\n\t\t);\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof GxsExchange gxsExchangeItem)\n\t\t{\n\t\t\tif (gxsExchangeItem.getTransactionId() != 0)\n\t\t\t{\n\t\t\t\thandleTransaction(sender, gxsExchangeItem);\n\t\t\t}\n\t\t\telse if (item instanceof GxsSyncGroupRequestItem gxsSyncGroupRequestItem)\n\t\t\t{\n\t\t\t\thandleGxsSyncGroupRequestItem(sender, gxsSyncGroupRequestItem);\n\t\t\t}\n\t\t\telse if (item instanceof GxsSyncMessageRequestItem gxsSyncMessageRequestItem)\n\t\t\t{\n\t\t\t\thandleGxsSyncMessageRequestItem(sender, gxsSyncMessageRequestItem);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tswitch (item)\n\t\t\t{\n\t\t\t\tcase GxsSyncGroupStatsItem gxsSyncGroupStatsItem -> handleGxsSyncGroupStats(sender, gxsSyncGroupStatsItem);\n\t\t\t\tcase GxsSyncNotifyItem gxsSyncNotifyItem -> handleGxsSyncNotifyItem(sender, gxsSyncNotifyItem);\n\t\t\t\tcase null, default -> log.error(\"Not a GxsExchange item: {}, ignoring\", item);\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void cleanup()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\t/**\n\t * Syncs automatically each SYNCHRONIZATION_DELAY, unless a syncNow() was performed in between, in that case\n\t * skip until the next one.\n\t *\n\t * @param peerConnection the peer connection\n\t */\n\tprivate void autoSync(PeerConnection peerConnection)\n\t{\n\t\tvar lastSync = (Instant) peerConnection.getServiceData(this, KEY_LAST_SYNC_REQUEST).orElse(Instant.EPOCH);\n\t\tif (Duration.between(lastSync, Instant.now()).compareTo(SYNCHRONIZATION_DELAY.minusSeconds(1)) > 0)\n\t\t{\n\t\t\tsyncNow(peerConnection);\n\t\t}\n\t}\n\n\tprivate void syncNow(PeerConnection peerConnection)\n\t{\n\t\tvar gxsSyncGroupRequestItem = new GxsSyncGroupRequestItem(gxsHelperService.getLastPeerGroupsUpdate(peerConnection.getLocation(), getServiceType()));\n\t\tlog.debug(\"Asking {} for last local sync {}\", peerConnection, log.isDebugEnabled() ? Instant.ofEpochSecond(gxsSyncGroupRequestItem.getLastUpdated()) : null);\n\t\tpeerConnectionManager.writeItem(peerConnection, gxsSyncGroupRequestItem, this);\n\t\tpeerConnection.putServiceData(this, KEY_LAST_SYNC_REQUEST, Instant.now());\n\t}\n\n\tprivate void manageAll()\n\t{\n\t\tcheckPendingGroupsAndMessages();\n\t\taskGroupStatisticsIfNeeded();\n\t}\n\n\tprivate void checkPendingGroupsAndMessages()\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar randomPeer = peerConnectionManager.getRandomPeer();\n\t\t\tif (randomPeer != null)\n\t\t\t{\n\t\t\t\tverifyAndStoreGroups(randomPeer, pendingGxsGroups.keySet());\n\t\t\t\tverifyAndStoreMessages(randomPeer, pendingGxsMessages.keySet());\n\t\t\t}\n\t\t\tpendingGxsGroups.entrySet().removeIf(gxsGroupItemLongEntry -> gxsGroupItemLongEntry.getValue() < 0);\n\t\t\tpendingGxsMessages.entrySet().removeIf(gxsMessageItemLongEntry -> gxsMessageItemLongEntry.getValue() < 0);\n\t\t}\n\t}\n\n\tprivate void askGroupStatisticsIfNeeded()\n\t{\n\t\tvar now = Instant.now();\n\t\tif (Duration.between(lastGroupStatistics, now).compareTo(GROUP_STATISTICS_DELAY) <= 0)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tlastGroupStatistics = now;\n\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar randomPeer = peerConnectionManager.getRandomPeer();\n\t\t\tif (randomPeer != null)\n\t\t\t{\n\t\t\t\tvar ids = gxsHelperService.findGroupsToRequestStats(now, GROUP_STATISTICS_DELAY);\n\t\t\t\tids.forEach(gxsId -> peerConnectionManager.writeItem(randomPeer, new GxsSyncGroupStatsItem(RequestType.REQUEST, gxsId), this));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void handleGxsSyncNotifyItem(PeerConnection peerConnection, GxsSyncNotifyItem item)\n\t{\n\t\tlog.debug(\"Got sync notify {} from {}\", item, peerConnection);\n\n\t\tsyncNow(peerConnection);\n\t\tsyncMessages(peerConnection);\n\t}\n\n\tprivate void handleGxsSyncGroupStats(PeerConnection peerConnection, GxsSyncGroupStatsItem item)\n\t{\n\t\tlog.debug(\"Got group stat {} from {}\", item, peerConnection);\n\n\t\tif (item.getRequestType() == RequestType.REQUEST)\n\t\t{\n\t\t\tgxsHelperService.findGroupStatsByGxsId(item.getGxsId())\n\t\t\t\t\t.ifPresent(gxsSyncGroupStatsItem -> peerConnectionManager.writeItem(peerConnection, gxsSyncGroupStatsItem, this));\n\t\t}\n\t\telse if (item.getRequestType() == RequestType.RESPONSE)\n\t\t{\n\t\t\tgxsHelperService.updateGroupStats(item);\n\t\t}\n\t}\n\n\tprotected void sendSyncNotification(PeerConnection peerConnection)\n\t{\n\t\tCompletableFuture.runAsync((NoSuppressedRunnable) () -> {\n\t\t\ttry\n\t\t\t{\n\t\t\t\tTimeUnit.SECONDS.sleep(1);\n\t\t\t}\n\t\t\tcatch (InterruptedException _)\n\t\t\t{\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tvar gxsSyncNotifyItem = new GxsSyncNotifyItem();\n\t\t\tlog.debug(\"Sending sync notification to {}\", peerConnection);\n\t\t\tpeerConnectionManager.writeItem(peerConnection, gxsSyncNotifyItem, this);\n\t\t});\n\t}\n\n\tprivate void handleGxsSyncGroupRequestItem(PeerConnection peerConnection, GxsSyncGroupRequestItem item)\n\t{\n\t\tlog.debug(\"{} sent group {}\", peerConnection, item);\n\n\t\tvar transactionId = getNextTransactionId(peerConnection);\n\t\tvar since = Instant.ofEpochSecond(item.getLastUpdated());\n\n\t\tvar latestGroup = areGroupUpdatesAvailableForPeer(since);\n\t\tif (latestGroup != null)\n\t\t{\n\t\t\tlog.debug(\"Group updates available, sending ids...\");\n\t\t\tList<GxsSyncGroupItem> items = new ArrayList<>();\n\n\t\t\tonAvailableGroupListRequest(peerConnection).forEach(gxsGroupItem -> {\n\t\t\t\tif (isGxsAllowedForPeer(peerConnection, gxsGroupItem))\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Adding group id of item: {}\", gxsGroupItem);\n\t\t\t\t\tvar gxsSyncGroupItem = new GxsSyncGroupItem(\n\t\t\t\t\t\t\tRESPONSE,\n\t\t\t\t\t\t\tgxsGroupItem,\n\t\t\t\t\t\t\ttransactionId);\n\n\t\t\t\t\titems.add(gxsSyncGroupItem);\n\t\t\t\t}\n\t\t\t});\n\t\t\t// the items are included in a transaction (they all have the same transaction number)\n\n\t\t\tlog.debug(\"Calling transaction for group, number of items: {}\", items.size());\n\t\t\tgxsTransactionManager.startOutgoingTransactionForGroupListResponse(\n\t\t\t\t\tpeerConnection,\n\t\t\t\t\titems,\n\t\t\t\t\tlatestGroup,\n\t\t\t\t\ttransactionId,\n\t\t\t\t\tthis\n\t\t\t);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"No group updates available\");\n\t\t}\n\n\t\t// XXX: check if the peer is subscribed, encrypt or not the group, etc... it's rsgxsnetservice.cc/handleRecvSyncGroup we might not need that for gxsid transferts\n\n\t\t// XXX: to handle the synchronization we must know which tables to use, then it's generic\n\t}\n\n\tprivate void handleGxsSyncMessageRequestItem(PeerConnection peerConnection, GxsSyncMessageRequestItem item)\n\t{\n\t\tlog.debug(\"{} sent message {}\", peerConnection, item);\n\n\t\tvar transactionId = getNextTransactionId(peerConnection);\n\t\tvar lastUpdated = Instant.ofEpochSecond(item.getLastUpdated());\n\t\tvar since = Instant.ofEpochSecond(item.getLimit());\n\n\t\tvar latestMessage = areMessageUpdatesAvailableForPeer(item.getGxsId(), lastUpdated, since);\n\t\tif (latestMessage != null)\n\t\t{\n\t\t\tlog.debug(\"New messages available, sending ids...\");\n\t\t\tList<GxsSyncMessageItem> items = new ArrayList<>();\n\n\t\t\tonPendingMessageListRequest(peerConnection, item.getGxsId(), since).forEach(gxsMessageItem -> {\n\t\t\t\tlog.debug(\"Adding message id of item {}\", gxsMessageItem);\n\t\t\t\tvar gxsSyncMessageItem = new GxsSyncMessageItem(\n\t\t\t\t\t\tGxsSyncMessageItem.RESPONSE,\n\t\t\t\t\t\tgxsMessageItem,\n\t\t\t\t\t\ttransactionId);\n\n\t\t\t\titems.add(gxsSyncMessageItem);\n\t\t\t});\n\n\t\t\tlog.debug(\"Calling transaction for message, number of items: {}\", items.size());\n\t\t\tgxsTransactionManager.startOutgoingTransactionForMessageListResponse(\n\t\t\t\t\tpeerConnection,\n\t\t\t\t\titems,\n\t\t\t\t\tlatestMessage,\n\t\t\t\t\ttransactionId,\n\t\t\t\t\tthis\n\t\t\t);\n\t\t}\n\n\t\t// XXX: maybe some more to do, check rsgxsnetservice.cc/handleRecvSyncMsg\n\t}\n\n\tprivate void handleTransaction(PeerConnection peerConnection, GxsExchange item)\n\t{\n\t\tif (item instanceof GxsTransactionItem gxsTransactionItem)\n\t\t{\n\t\t\tgxsTransactionManager.processIncomingTransaction(peerConnection, gxsTransactionItem, this);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tgxsTransactionManager.addIncomingItemToTransaction(peerConnection, item, this);\n\t\t}\n\t}\n\n\tprotected synchronized int getNextTransactionId(PeerConnection peerConnection)\n\t{\n\t\t// The transaction id needs to be stored globally on the peer connection as multiple services can use them\n\t\tvar transactionId = (int) peerConnection.getPeerData(KEY_GXS_TRANSACTION_ID).orElse(0) + 1;\n\t\tpeerConnection.putPeerData(KEY_GXS_TRANSACTION_ID, transactionId);\n\t\treturn transactionId;\n\t}\n\n\tprivate Instant areGroupUpdatesAvailableForPeer(Instant lastPeerUpdate)\n\t{\n\t\tvar lastServiceUpdate = gxsHelperService.getLastServiceGroupsUpdate(getServiceType());\n\t\t// XXX: there should be a way to detect if the peer is sending a lastPeerUpdate several times (means the transaction isn't complete yet)\n\t\tif (lastPeerUpdate.isBefore(lastServiceUpdate))\n\t\t{\n\t\t\treturn lastServiceUpdate;\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate Instant areMessageUpdatesAvailableForPeer(GxsId gxsId, Instant lastPeerUpdate, Instant since)\n\t{\n\t\tvar groupList = onGroupListRequest(Set.of(gxsId));\n\t\tif (groupList.isEmpty())\n\t\t{\n\t\t\tlog.debug(\"Peer requested unavailable group {}\", gxsId); // Switched severity do debug instead of warn because RS seems to request without checking\n\t\t\treturn null;\n\t\t}\n\n\t\tInstant latestMessage = Instant.EPOCH;\n\t\tgroupList = groupList.stream()\n\t\t\t\t.filter(g -> g.isSubscribed() &&\n\t\t\t\t\t\tg.getLastUpdated() != null &&\n\t\t\t\t\t\tlastPeerUpdate.isBefore(g.getLastUpdated()) &&\n\t\t\t\t\t\tg.getLastUpdated().isAfter(since))\n\t\t\t\t.toList();\n\n\t\tif (groupList.isEmpty())\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tfor (var group : groupList)\n\t\t{\n\t\t\tif (group.getLastUpdated().isAfter(latestMessage))\n\t\t\t{\n\t\t\t\tlatestMessage = group.getLastUpdated();\n\t\t\t}\n\t\t}\n\t\treturn latestMessage;\n\t}\n\n\tprivate boolean isGxsAllowedForPeer(PeerConnection peerConnection, G item)\n\t{\n\t\treturn switch (item.getCircleType())\n\t\t{\n\t\t\tcase LOCAL, EXTERNAL_SELF, YOUR_EYES_ONLY -> false;\n\t\t\tcase PUBLIC -> true;\n\t\t\tcase UNKNOWN -> true; // Identities don't have the circle type initialized\n\t\t\tcase EXTERNAL -> false; // XXX: should be true and the data should be encrypted then\n\t\t\tcase YOUR_FRIENDS_ONLY -> isGxsAllowedForFriendGroup(peerConnection, item);\n\t\t};\n\t}\n\n\tprivate boolean isGxsAllowedForFriendGroup(PeerConnection peerConnection, G item)\n\t{\n\t\t// XXX: implement rsgxsnetservice/checkPermissionsForFriendGroup()\n\t\treturn false;\n\t}\n\n\t/**\n\t * Processes the transaction.\n\t *\n\t * @param peerConnection the peer connection who sent the items\n\t * @param transaction    the transaction to process\n\t */\n\tpublic void processItems(PeerConnection peerConnection, Transaction<?> transaction)\n\t{\n\t\tif (isEmpty(transaction.getItems()))\n\t\t{\n\t\t\tlog.debug(\"{} has no items in the transaction\", peerConnection);\n\t\t\treturn; // nothing to do\n\t\t}\n\n\t\tif (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_GROUP_LIST_RESPONSE))\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar gxsIdsMap = ((List<GxsSyncGroupItem>) transaction.getItems()).stream()\n\t\t\t\t\t.collect(toMap(GxsSyncGroupItem::getGxsId, gxsSyncGroupItem -> Instant.ofEpochSecond(gxsSyncGroupItem.getPublishTimestamp())));\n\t\t\tlog.debug(\"{} has the following group ids (new or updates) for us (total: {}): {} ...\", peerConnection, gxsIdsMap.size(), gxsIdsMap.keySet().stream().limit(10).toList());\n\t\t\trequestGxsGroups(peerConnection, onAvailableGroupListResponse(gxsIdsMap));\n\t\t}\n\t\telse if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_GROUP_LIST_REQUEST))\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar gxsIds = ((List<GxsSyncGroupItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(GxsSyncGroupItem::getGxsId).collect(toSet());\n\t\t\tlog.debug(\"{} wants the following group ids (total: {}): {} ...\", peerConnection, gxsIds.size(), gxsIds.stream().limit(10).toList());\n\t\t\tsendGxsGroups(peerConnection, onGroupListRequest(gxsIds));\n\t\t}\n\t\telse if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_GROUPS))\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar gxsGroupItems = ((List<GxsTransferGroupItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(this::convertTransferGroupToGxsGroup)\n\t\t\t\t\t.toList();\n\n\t\t\tverifyAndStoreGroups(peerConnection, gxsGroupItems);\n\t\t\tif (!gxsGroupItems.isEmpty())\n\t\t\t{\n\t\t\t\tlog.debug(\"{} sent groups\", peerConnection);\n\t\t\t\tgxsHelperService.setLastPeerGroupsUpdate(peerConnection.getLocation(), transaction.getUpdated(), getServiceType());\n\t\t\t\tgxsHelperService.setLastServiceGroupsUpdateNow(getServiceType());\n\t\t\t\tpeerConnectionManager.doForAllPeersExceptSender(this::sendSyncNotification, peerConnection, this);\n\t\t\t}\n\t\t}\n\t\telse if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_MESSAGE_LIST_RESPONSE))\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar gxsId = ((List<GxsSyncMessageItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(GxsSyncMessageItem::getGxsId).findFirst().orElse(null);\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar msgIds = ((List<GxsSyncMessageItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(GxsSyncMessageItem::getMsgId).collect(toSet());\n\t\t\tlog.debug(\"{} has the following msg ids for group {} (new) for us (total: {}): {} ...\", peerConnection, gxsId, msgIds.size(), msgIds.stream().limit(10).toList());\n\t\t\tvar messagesWanted = onMessageListResponse(gxsId, msgIds);\n\t\t\trequestGxsMessages(peerConnection, gxsId, messagesWanted);\n\t\t\tif (messagesWanted.isEmpty())\n\t\t\t{\n\t\t\t\t// If there was no message, it means we got them all already (from another peer or multiple transactions). We can set the timestamp.\n\t\t\t\tupdateLastMessageUpdateAndBroadcastToOthers(peerConnection, gxsId, transaction.getUpdated());\n\t\t\t}\n\t\t}\n\t\telse if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_MESSAGE_LIST_REQUEST))\n\t\t{\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar gxsId = ((List<GxsSyncMessageItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(GxsSyncMessageItem::getGxsId).findFirst().orElse(null);\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar msgIds = ((List<GxsSyncMessageItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(GxsSyncMessageItem::getMsgId).collect(toSet());\n\t\t\tlog.debug(\"{} wants the following msg ids for group {} (total: {}): {} ...\", peerConnection, gxsId, msgIds.size(), msgIds.stream().limit(10).toList());\n\t\t\tsendGxsMessages(peerConnection, onMessageListRequest(gxsId, msgIds));\n\t\t}\n\t\telse if (transaction.getTransactionFlags().contains(TransactionFlags.TYPE_MESSAGES))\n\t\t{\n\t\t\t// This contains the message items, the votes and the comments\n\t\t\t@SuppressWarnings(\"unchecked\")\n\t\t\tvar gxsMessageItems = ((List<GxsTransferMessageItem>) transaction.getItems()).stream()\n\t\t\t\t\t.map(this::convertTransferGroupToGxsMessage)\n\t\t\t\t\t.sorted(Comparator.comparing(GxsMessageItem::getPublished)) // Get older message first to facilitate marking messages as edited\n\t\t\t\t\t.toList();\n\n\t\t\tverifyAndStoreMessages(peerConnection, gxsMessageItems);\n\t\t\tif (!gxsMessageItems.isEmpty())\n\t\t\t{\n\t\t\t\tvar gxsId = gxsMessageItems.getFirst().getGxsId();\n\n\t\t\t\tlog.debug(\"{} sent messages for group {}\", peerConnection, gxsId);\n\t\t\t\tif (!ongoingGxsMessageTransfers.contains(gxsId))\n\t\t\t\t{\n\t\t\t\t\t// If there's no more ongoing transfer for those messages, we can mark them as finished.\n\t\t\t\t\tupdateLastMessageUpdateAndBroadcastToOthers(peerConnection, gxsId, transaction.getUpdated());\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Unknown transaction {}\", transaction);\n\t\t}\n\t}\n\n\tprivate void updateLastMessageUpdateAndBroadcastToOthers(PeerConnection peerConnection, GxsId group, Instant when)\n\t{\n\t\tgxsHelperService.setLastPeerMessageUpdate(peerConnection.getLocation(), group, when, getServiceType());\n\t\tpeerConnectionManager.doForAllPeersExceptSender(this::sendSyncNotification, peerConnection, this);\n\t}\n\n\tprivate void verifyAndStoreGroups(PeerConnection peerConnection, Collection<G> groups)\n\t{\n\t\tList<G> savedGroups = new ArrayList<>(groups.size());\n\n\t\tfor (var group : groups)\n\t\t{\n\t\t\tvar data = ItemUtils.serializeItemForSignature(group, this);\n\t\t\tvar validation = verifyGroupAdmin(group, data);\n\n\t\t\t// Validate author signature, if needed\n\t\t\tif (validation == VerificationStatus.OK && (group.getAuthorGxsId() != null || gxsAuthentication.isAuthorSigningGroups()))\n\t\t\t{\n\t\t\t\tif (group.getAuthorGxsId() == null)\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Failed to validate group {}: missing author id\", group);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tvalidation = verifyGroupAuthor(peerConnection, group, data);\n\t\t\t\tif (validation == VerificationStatus.DELAYED)\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If this is a group update, validate its admin signature using the public key we already have\n\t\t\tif (validation == VerificationStatus.OK)\n\t\t\t{\n\t\t\t\tvalidation = verifyGroupForUpdate(peerConnection, group, data);\n\t\t\t}\n\n\t\t\t// Save the group if everything is OK\n\t\t\tif (validation == VerificationStatus.OK)\n\t\t\t{\n\t\t\t\tgxsHelperService.saveGroup(group, this::onGroupReceived).ifPresent(savedGroups::add);\n\t\t\t}\n\n\t\t\t// If the group verification was delayed, remove it\n\t\t\tpendingGxsGroups.computeIfPresent(group, (_, _) -> -1L);\n\t\t}\n\n\t\tif (!savedGroups.isEmpty())\n\t\t{\n\t\t\tonGroupsSaved(savedGroups);\n\t\t}\n\t}\n\n\tprivate VerificationStatus verifyGroupAdmin(G group, byte[] data)\n\t{\n\t\tvar adminPublicKey = group.getAdminPublicKey();\n\t\tif (adminPublicKey == null)\n\t\t{\n\t\t\tlog.warn(\"Failed to validate group {}: missing admin key\", group);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\n\t\tvar adminSignature = group.getAdminSignature();\n\t\tif (adminSignature == null)\n\t\t{\n\t\t\tlog.warn(\"Failed to validate group {}: missing admin signature\", group);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\n\t\tif (!RSA.verify(adminPublicKey, adminSignature, data))\n\t\t{\n\t\t\tlog.warn(\"Failed to validate group {}: wrong admin signature\", group);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\t\treturn VerificationStatus.OK;\n\t}\n\n\tprivate VerificationStatus verifyGroupAuthor(PeerConnection peerConnection, G gxsGroupItem, byte[] data)\n\t{\n\t\tif (gxsGroupItem.getAuthorSignature() == null)\n\t\t{\n\t\t\tlog.warn(\"Missing author signature for group {}\", gxsGroupItem);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\n\t\tvar authorIdentity = identityManager.getGxsGroup(peerConnection, gxsGroupItem.getAuthorGxsId());\n\t\tif (authorIdentity == null)\n\t\t{\n\t\t\tlog.warn(\"Delaying verification of group {} (author: {})\", gxsGroupItem, gxsGroupItem.getAuthorGxsId());\n\t\t\tvar existingDelay = pendingGxsGroups.putIfAbsent(gxsGroupItem, PENDING_VERIFICATION_MAX.toSeconds());\n\t\t\tif (existingDelay != null)\n\t\t\t{\n\t\t\t\tvar newDelay = existingDelay - PENDING_VERIFICATION_DELAY.toSeconds();\n\t\t\t\tpendingGxsGroups.put(gxsGroupItem, newDelay);\n\t\t\t\tif (newDelay < 0)\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Failed to validate group {}: timeout exceeded\", gxsGroupItem);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn VerificationStatus.DELAYED;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar authorAdminPublicKey = authorIdentity.getAdminPublicKey();\n\t\t\tif (authorAdminPublicKey == null)\n\t\t\t{\n\t\t\t\tlog.warn(\"Failed to validate group {}: missing author admin key\", gxsGroupItem);\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\n\t\t\tvar authorSignature = gxsGroupItem.getAuthorSignature();\n\t\t\tif (authorSignature == null)\n\t\t\t{\n\t\t\t\tlog.warn(\"Missing author signature for group {}\", gxsGroupItem);\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\n\t\t\tif (RSA.verify(authorAdminPublicKey, authorSignature, data))\n\t\t\t{\n\t\t\t\treturn VerificationStatus.OK;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Failed to validate group {}: wrong author signature\", gxsGroupItem);\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate VerificationStatus verifyGroupForUpdate(PeerConnection peerConnection, G group, byte[] data)\n\t{\n\t\tvar existingGroup = gxsHelperService.getGroup(group.getGxsId());\n\t\tif (existingGroup != null)\n\t\t{\n\t\t\t// Validate the new group using the old key to certify this is an upgrade\n\t\t\tvar existingAdminPublicKey = existingGroup.getAdminPublicKey();\n\t\t\tif (!group.getPublished().isAfter(existingGroup.getPublished()))\n\t\t\t{\n\t\t\t\tlog.warn(\"Failed to validate group {} for update: new group timestamp {} <= old group timestamp {}\", group.getPublished(), existingGroup.getPublished(), group.getPublished());\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\n\t\t\tif (RSA.verify(existingAdminPublicKey, group.getAdminSignature(), data))\n\t\t\t{\n\t\t\t\t// Copy the fields we want to retain.\n\t\t\t\tgroup.retainValues(existingGroup);\n\t\t\t\t// XXX: private keys? do we have groups with private keys? update should not replace them but keep the old ones\n\t\t\t\tif (group.getCircleType() == GxsCircleType.YOUR_FRIENDS_ONLY)\n\t\t\t\t{\n\t\t\t\t\tgroup.setOriginator(peerConnection.getLocation().getLocationIdentifier());\n\t\t\t\t}\n\t\t\t\treturn VerificationStatus.OK;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (isSameKey(existingAdminPublicKey, group.getAdminPublicKey()))\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Failed to validate group {} for update: wrong admin signature\", group);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Failed to validate group {} for update: new public key doesn't match the old one\", group);\n\t\t\t\t}\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tgroup.setOriginator(peerConnection.getLocation().getLocationIdentifier());\n\t\t\treturn VerificationStatus.OK;\n\t\t}\n\t}\n\n\tprivate static boolean isSameKey(PublicKey a, PublicKey b)\n\t{\n\t\treturn Arrays.equals(a.getEncoded(), b.getEncoded());\n\t}\n\n\tprivate G createGxsGroupItem()\n\t{\n\t\tG gxsGroupItem;\n\n\t\ttry\n\t\t{\n\t\t\tgxsGroupItem = itemGroupClass.getDeclaredConstructor().newInstance();\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Failed to instantiate \" + itemGroupClass.getSimpleName() + \" missing empty constructor?\");\n\t\t}\n\t\treturn gxsGroupItem;\n\t}\n\n\tprivate G convertTransferGroupToGxsGroup(GxsTransferGroupItem fromItem)\n\t{\n\t\tvar toItem = createGxsGroupItem();\n\n\t\tfromItem.toGxsGroupItem(toItem);\n\n\t\treturn toItem;\n\t}\n\n\tprivate void sendGxsGroups(PeerConnection peerConnection, List<G> gxsGroupItems)\n\t{\n\t\tvar transactionId = getNextTransactionId(peerConnection);\n\t\tList<GxsTransferGroupItem> items = new ArrayList<>();\n\t\tgxsGroupItems.forEach(gxsGroupItem -> items.add(new GxsTransferGroupItem(gxsGroupItem, transactionId, getServiceType())));\n\n\t\tgxsTransactionManager.startOutgoingTransactionForGroupTransfer(\n\t\t\t\tpeerConnection,\n\t\t\t\titems,\n\t\t\t\tgxsHelperService.getLastServiceGroupsUpdate(getServiceType()),\n\t\t\t\ttransactionId,\n\t\t\t\tthis\n\t\t);\n\t}\n\n\tpublic void requestGxsGroups(PeerConnection peerConnection, Collection<GxsId> ids) // XXX: maybe use a future to know when the group arrived? it's possible by keeping a list of transactionIds then answering once the answer comes back\n\t{\n\t\tif (isEmpty(ids))\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tvar transactionId = getNextTransactionId(peerConnection);\n\t\tList<GxsSyncGroupItem> items = new ArrayList<>();\n\n\t\tids.forEach(gxsId -> items.add(new GxsSyncGroupItem(REQUEST, gxsId, transactionId)));\n\n\t\tgxsTransactionManager.startOutgoingTransactionForGroupListRequest(peerConnection, items, transactionId, this);\n\t}\n\n\tprivate void verifyAndStoreMessages(PeerConnection peerConnection, Collection<GxsMessageItem> messages)\n\t{\n\t\tList<M> savedMessages = new ArrayList<>();\n\t\tList<CommentMessageItem> savedComments = new ArrayList<>();\n\t\tList<VoteMessageItem> savedVotes = new ArrayList<>();\n\t\tMap<GxsId, Instant> lastPostedMap = new HashMap<>();\n\n\t\tfor (var message : messages)\n\t\t{\n\t\t\tvar data = ItemUtils.serializeItemForSignature(message, this);\n\t\t\tvar validation = VerificationStatus.OK;\n\n\t\t\tif (gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_PUBLISH : ROOT_NEEDS_PUBLISH))\n\t\t\t{\n\t\t\t\tvalidation = verifyMessagePublish(message, data);\n\t\t\t}\n\n\t\t\t// Check requirements, but if the message has been signed anyway, we still need to validate it\n\t\t\tif (validation == VerificationStatus.OK && (message.hasAuthor() || gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_AUTHOR : ROOT_NEEDS_AUTHOR)))\n\t\t\t{\n\t\t\t\tvalidation = verifyMessageAuthor(peerConnection, message, data);\n\t\t\t\tif (validation == VerificationStatus.DELAYED)\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Save the message if everything is OK\n\t\t\tif (validation == VerificationStatus.OK)\n\t\t\t{\n\t\t\t\tswitch (message)\n\t\t\t\t{\n\t\t\t\t\tcase CommentMessageItem commentMessageItem -> gxsHelperService.saveComment(commentMessageItem, this::onCommentReceived).ifPresent(savedComments::add);\n\t\t\t\t\tcase VoteMessageItem voteMessageItem -> gxsHelperService.saveVote(voteMessageItem, this::onVoteReceived).ifPresent(savedVotes::add);\n\t\t\t\t\tdefault ->\n\t\t\t\t\t\t//noinspection unchecked\n\t\t\t\t\t\t\tgxsHelperService.saveMessage((M) message, this::onMessageReceived).ifPresent(savedMessages::add);\n\t\t\t\t}\n\t\t\t\tvar lastPosted = lastPostedMap.computeIfAbsent(message.getGxsId(), _ -> message.getPublished());\n\t\t\t\tif (message.getPublished().isAfter(lastPosted))\n\t\t\t\t{\n\t\t\t\t\tlastPostedMap.put(message.getGxsId(), message.getPublished());\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If the message verification was delayed, remove it\n\t\t\tpendingGxsMessages.computeIfPresent(message, (_, _) -> -1L);\n\t\t}\n\n\t\tif (!savedMessages.isEmpty())\n\t\t{\n\t\t\tmarkOriginalMessageAsHidden(savedMessages);\n\t\t\tonMessagesSaved(savedMessages);\n\t\t}\n\t\tif (!savedComments.isEmpty())\n\t\t{\n\t\t\tmarkOriginalMessageAsHidden(savedComments);\n\t\t\tonCommentsSaved(savedComments);\n\t\t}\n\t\tif (!savedVotes.isEmpty())\n\t\t{\n\t\t\tmarkOriginalMessageAsHidden(savedVotes);\n\t\t\tonVotesSaved(savedVotes);\n\t\t}\n\t\tlastPostedMap.forEach(gxsHelperService::updateLastPosted);\n\t}\n\n\tprotected void markOriginalMessageAsHidden(Collection<? extends GxsMessageItem> gxsMessageItems)\n\t{\n\t\tgxsMessageItems.forEach(gxsMessageItem -> {\n\t\t\tif (gxsMessageItem.getOriginalMsgId() != null && !gxsMessageItem.getOriginalMsgId().equals(gxsMessageItem.getMsgId()))\n\t\t\t{\n\t\t\t\tgxsHelperService.overrideMessage(gxsMessageItem.getGxsId(), gxsMessageItem.getOriginalMsgId(), gxsMessageItem.getAuthorGxsId());\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate VerificationStatus verifyMessagePublish(GxsMessageItem message, byte[] data)\n\t{\n\t\tvar group = gxsHelperService.getGroup(message.getGxsId());\n\t\tif (group == null)\n\t\t{\n\t\t\tlog.warn(\"Failed to find group for message: {}, dropping\", message);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\t\tvar publicKey = group.getPublishPublicKey();\n\t\tif (publicKey == null)\n\t\t{\n\t\t\tlog.warn(\"Failed to find group publish public key for message: {}, dropping\", message);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\t\tvar signature = message.getPublishSignature();\n\t\tif (signature == null)\n\t\t{\n\t\t\tlog.warn(\"Missing publish signature for message: {}, dropping\", message);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\n\t\tif (RSA.verify(publicKey, signature, data))\n\t\t{\n\t\t\treturn VerificationStatus.OK;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.warn(\"Failed to validate message {}: wrong publish signature\", message);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\t}\n\n\tprivate VerificationStatus verifyMessageAuthor(PeerConnection peerConnection, GxsMessageItem message, byte[] data)\n\t{\n\t\tvar signature = message.getAuthorSignature();\n\t\tif (signature == null)\n\t\t{\n\t\t\tlog.warn(\"Missing author signature for message {}\", message);\n\t\t\treturn VerificationStatus.FAILED;\n\t\t}\n\n\t\tvar authorIdentity = identityManager.getGxsGroup(peerConnection, message.getAuthorGxsId());\n\t\tif (authorIdentity == null)\n\t\t{\n\t\t\tlog.warn(\"Delaying verification of message {}\", message);\n\t\t\tvar existingDelay = pendingGxsMessages.putIfAbsent(message, PENDING_VERIFICATION_MAX.toSeconds());\n\t\t\tif (existingDelay != null)\n\t\t\t{\n\t\t\t\tvar newDelay = existingDelay - PENDING_VERIFICATION_DELAY.toSeconds();\n\t\t\t\tpendingGxsMessages.put(message, newDelay);\n\t\t\t\tif (newDelay < 0)\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Failed to validate message {}: timeout exceeded\", message);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn VerificationStatus.DELAYED;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar publicKey = authorIdentity.getAdminPublicKey();\n\t\t\tif (publicKey == null)\n\t\t\t{\n\t\t\t\tlog.warn(\"Failed to find author admin public key for message {}\", message);\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\t\t\tif (RSA.verify(publicKey, signature, data))\n\t\t\t{\n\t\t\t\t// XXX: check for reputation here, if reputation is too low, remove\n\t\t\t\treturn VerificationStatus.OK;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"Failed to validate message {}: wrong author signature\", message);\n\t\t\t\treturn VerificationStatus.FAILED;\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void sendGxsMessages(PeerConnection peerConnection, List<? extends GxsMessageItem> gxsMessageItems)\n\t{\n\t\tvar transactionId = getNextTransactionId(peerConnection);\n\t\tList<GxsTransferMessageItem> items = new ArrayList<>();\n\n\t\tInstant latestMessage = Instant.EPOCH;\n\t\tfor (var gxsMessageItem : gxsMessageItems)\n\t\t{\n\t\t\titems.add(new GxsTransferMessageItem(gxsMessageItem, transactionId, getServiceType()));\n\t\t\tif (gxsMessageItem.getPublished().isAfter(latestMessage))\n\t\t\t{\n\t\t\t\tlatestMessage = gxsMessageItem.getPublished();\n\t\t\t}\n\t\t}\n\n\t\tgxsTransactionManager.startOutgoingTransactionForMessageTransfer(\n\t\t\t\tpeerConnection,\n\t\t\t\titems,\n\t\t\t\tlatestMessage,\n\t\t\t\ttransactionId,\n\t\t\t\tthis\n\t\t);\n\t}\n\n\tpublic void requestGxsMessages(PeerConnection peerConnection, GxsId gxsId, Collection<MsgId> msgIds)\n\t{\n\t\tif (isEmpty(msgIds))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar transactionId = getNextTransactionId(peerConnection);\n\t\tList<GxsSyncMessageItem> items = new ArrayList<>();\n\n\t\t// Ask for MESSAGES_PER_TRANSACTIONS messages at a time. This is done to avoid\n\t\t// overflowing the peer's queue.\n\t\tvar count = 0;\n\t\tfor (var msgId : msgIds)\n\t\t{\n\t\t\titems.add(new GxsSyncMessageItem(GxsSyncMessageItem.REQUEST, gxsId, msgId, transactionId));\n\n\t\t\tif (++count == MESSAGES_PER_TRANSACTIONS)\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// Mark/unmark as ongoing transaction to make sure\n\t\t// we update the peer timestamp when needed.\n\t\tif (count == MESSAGES_PER_TRANSACTIONS && msgIds.size() > MESSAGES_PER_TRANSACTIONS)\n\t\t{\n\t\t\tongoingGxsMessageTransfers.add(gxsId);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tongoingGxsMessageTransfers.remove(gxsId);\n\t\t}\n\n\t\tgxsTransactionManager.startOutgoingTransactionForMessageListRequest(peerConnection, items, transactionId, this);\n\t}\n\n\tprivate M createGxsMessageItem()\n\t{\n\t\tM gxsMessageItem;\n\n\t\ttry\n\t\t{\n\t\t\tgxsMessageItem = itemMessageClass.getDeclaredConstructor().newInstance();\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Failed to instantiate \" + itemMessageClass.getSimpleName() + \" missing empty constructor?\");\n\t\t}\n\t\treturn gxsMessageItem;\n\t}\n\n\tprivate GxsMessageItem convertTransferGroupToGxsMessage(GxsTransferMessageItem fromItem)\n\t{\n\t\tvar subType = fromItem.getMessageType();\n\t\tvar toItem = switch (subType)\n\t\t{\n\t\t\tcase CommentMessageItem.SUBTYPE -> new CommentMessageItem();\n\t\t\tcase VoteMessageItem.SUBTYPE -> new VoteMessageItem();\n\t\t\tdefault -> createGxsMessageItem();\n\t\t};\n\t\treturn fromItem.toGxsMessageItem(toItem);\n\t}\n\n\tprotected G createGroup(String name, boolean needsPublish)\n\t{\n\t\tKeyPair adminKeyPair = RSA.generateKeys(GXS_KEY_SIZE);\n\t\tKeyPair publishKeyPair = null;\n\t\tif (needsPublish)\n\t\t{\n\t\t\tpublishKeyPair = RSA.generateKeys(GXS_KEY_SIZE);\n\t\t}\n\t\treturn createGroup(name, adminKeyPair, publishKeyPair);\n\t}\n\n\tprotected G createGroup(String name, KeyPair adminKeyPair, KeyPair publishKeyPair)\n\t{\n\t\tvar adminPrivateKey = (RSAPrivateKey) adminKeyPair.getPrivate();\n\t\tvar adminPublicKey = (RSAPublicKey) adminKeyPair.getPublic();\n\n\t\t// The GxsId is from the public admin key (n and e)\n\t\tvar gxsId = RSA.getGxsId(adminPublicKey);\n\n\t\tvar gxsGroupItem = createGxsGroupItem();\n\t\tgxsGroupItem.setGxsId(gxsId);\n\t\tgxsGroupItem.setName(name);\n\t\tgxsGroupItem.updatePublished(); // Needs to be called before we set any key (validFrom is computed from it)\n\t\tgxsGroupItem.setAdminKeys(adminPrivateKey, adminPublicKey, gxsGroupItem.getPublished(), null);\n\t\tif (publishKeyPair != null)\n\t\t{\n\t\t\tvar publishPrivateKey = (RSAPrivateKey) publishKeyPair.getPrivate();\n\t\t\tvar publishPublicKey = (RSAPublicKey) publishKeyPair.getPublic();\n\t\t\tgxsGroupItem.setPublishKeys(RSA.getGxsId(publishPublicKey), publishPrivateKey, publishPublicKey, gxsGroupItem.getPublished(), null);\n\t\t}\n\t\treturn gxsGroupItem;\n\t}\n\n\tprotected void signGroupIfNeeded(GxsGroupItem group)\n\t{\n\t\tif (group.isExternal())\n\t\t{\n\t\t\treturn; // Only sign our own groups\n\t\t}\n\n\t\t// Sign as admin\n\t\tvar data = ItemUtils.serializeItemForSignature(group, this);\n\t\tvar signature = RSA.sign(group.getAdminPrivateKey(), data);\n\t\tgroup.setAdminSignature(signature);\n\n\t\tif (group.getAuthorGxsId() != null || gxsAuthentication.isAuthorSigningGroups())\n\t\t{\n\t\t\tif (group.getAuthorGxsId() == null)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Missing author id for signing group \" + group);\n\t\t\t}\n\t\t\tvar author = identityManager.getGxsGroup(group.getAuthorGxsId());\n\t\t\tObjects.requireNonNull(author, \"Couldn't get own identity. Shouldn't happen (tm)\");\n\t\t\tvar authorSignature = RSA.sign(author.getAdminPrivateKey(), data);\n\t\t\tgroup.setAuthorSignature(authorSignature);\n\t\t}\n\t}\n\n\tprotected final class MessageBuilder\n\t{\n\t\tprivate final M gxsMessageItem;\n\t\tprivate final GxsGroupItem group;\n\t\tprivate final IdentityGroupItem author;\n\n\t\tpublic MessageBuilder(GxsGroupItem group, IdentityGroupItem author, String name)\n\t\t{\n\t\t\tthis.group = group;\n\t\t\tthis.author = author;\n\t\t\tgxsMessageItem = createGxsMessageItem();\n\t\t\tgxsMessageItem.setGxsId(group.getGxsId());\n\t\t\tgxsMessageItem.setName(name);\n\t\t\tif (author != null)\n\t\t\t{\n\t\t\t\tgxsMessageItem.setAuthorGxsId(author.getGxsId());\n\t\t\t}\n\t\t}\n\n\t\tpublic MessageBuilder originalMsgId(MsgId originalMsgId)\n\t\t{\n\t\t\tObjects.requireNonNull(originalMsgId, \"originalMsgId must not be null\");\n\t\t\tgxsMessageItem.setOriginalMsgId(originalMsgId);\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic MessageBuilder parentMsgId(MsgId parentMsgId)\n\t\t{\n\t\t\t// XXX: if parentId != 0L, then threadId must be set\n\t\t\tgxsMessageItem.setParentMsgId(parentMsgId);\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic M getMessageItem()\n\t\t{\n\t\t\treturn gxsMessageItem;\n\t\t}\n\n\t\tpublic M build()\n\t\t{\n\t\t\tgxsMessageItem.updatePublished();\n\t\t\t// XXX: serviceType? how? how does group do it?\n\n\t\t\t// The identifier is the sha1 hash of the data and meta (note: do not set any serialized fields after that call!)\n\t\t\tvar data = ItemUtils.serializeItemForSignature(gxsMessageItem, GxsRsService.this);\n\n\t\t\tvar md = new Sha1MessageDigest();\n\t\t\tmd.update(data);\n\t\t\tgxsMessageItem.setMsgId(new MsgId(md.getBytes()));\n\n\t\t\t// The signature is performed afterwards\n\t\t\tsignMessage(gxsMessageItem, data);\n\n\t\t\treturn gxsMessageItem;\n\t\t}\n\n\t\tprivate void signMessage(GxsMessageItem message, byte[] data)\n\t\t{\n\t\t\tif (gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_PUBLISH : ROOT_NEEDS_PUBLISH))\n\t\t\t{\n\t\t\t\tvar publishPrivateKey = group.getPublishPrivateKey();\n\t\t\t\tif (publishPrivateKey == null)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"Message \" + message + \" requires a publish key but there's none\");\n\t\t\t\t}\n\t\t\t\tvar signature = RSA.sign(publishPrivateKey, data);\n\t\t\t\tmessage.setPublishSignature(signature);\n\t\t\t}\n\n\t\t\tif (author != null || gxsAuthentication.getRequirements().contains(message.isChild() ? CHILD_NEEDS_AUTHOR : ROOT_NEEDS_AUTHOR))\n\t\t\t{\n\t\t\t\tif (author == null)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"Message \" + message + \" requires an author but there's none\");\n\t\t\t\t}\n\t\t\t\tvar signature = RSA.sign(author.getAdminPrivateKey(), data);\n\t\t\t\tmessage.setAuthorSignature(signature);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/GxsTransactionManager.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport io.xeres.app.application.events.PeerDisconnectedEvent;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.service.gxs.Transaction.Direction;\nimport io.xeres.app.xrs.service.gxs.item.*;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.util.ExecutorUtils;\nimport jakarta.annotation.PostConstruct;\nimport jakarta.annotation.PreDestroy;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\n\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ScheduledExecutorService;\n\nimport static io.xeres.app.xrs.service.gxs.Transaction.Direction.INCOMING;\nimport static io.xeres.app.xrs.service.gxs.Transaction.Direction.OUTGOING;\nimport static io.xeres.app.xrs.service.gxs.Transaction.State;\nimport static io.xeres.app.xrs.service.gxs.item.TransactionFlags.*;\n\n/**\n * Manages incoming and outgoing transactions.\n * Transactions work this way:\n * <p>\n * <b>Incoming transactions:</b>\n * <ul>\n *     <li>we receive a GxsTransactionItem with flag START which contains the expected number of items</li>\n *     <li>we send back a GxsTransactionItem with flag START_ACKNOWLEDGE</li>\n *     <li>the peer sends GxsExchange items</li>\n *     <li>once we have received all items, we send a GxsTransactionItem with flag END_SUCCESS</li>\n * </ul>\n * <p>\n * <b>Outgoing transactions:</b>\n * <ul>\n *     <li>we send a GxsTransactionItem with flag START which contains the expected number of items</li>\n *     <li>the peer sends back a GxsTransactionItem with flag START_ACKNOWLEDGE</li>\n *     <li>we send GxsExchange items to the peer</li>\n *     <li>once the peer has received all the items, it sends back a GxsTransactionItem with flag END_SUCCESS</li>\n * </ul>\n * <p>\n * <img src=\"doc-files/transaction.png\" alt=\"Transaction diagram\">\n * @see Transaction\n */\n@Service\npublic class GxsTransactionManager\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GxsTransactionManager.class);\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\n\tprivate final Map<LocationIdentifier, Map<Integer, Transaction<?>>> incomingTransactions = new ConcurrentHashMap<>();\n\tprivate final Map<LocationIdentifier, Map<Integer, Transaction<?>>> outgoingTransactions = new ConcurrentHashMap<>();\n\n\tprivate ScheduledExecutorService executorService;\n\n\tpublic GxsTransactionManager(PeerConnectionManager peerConnectionManager)\n\t{\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t}\n\n\t@PostConstruct\n\tprivate void init()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::cleanupTransactions,\n\t\t\t\tTransaction.TRANSACTION_TIMEOUT.toSeconds() + 30,\n\t\t\t\tTransaction.TRANSACTION_TIMEOUT.toSeconds());\n\t}\n\n\t@PreDestroy\n\tprivate void cleanup()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\t/**\n\t * Removes all transactions that have a timeout.\n\t */\n\tprivate void cleanupTransactions()\n\t{\n\t\tincomingTransactions.forEach((_, transactionMap) -> transactionMap.entrySet().removeIf(transaction -> transaction.getValue().hasTimedOut()));\n\t\toutgoingTransactions.forEach((_, transactionMap) -> transactionMap.entrySet().removeIf(transaction -> transaction.getValue().hasTimedOut()));\n\t}\n\n\t/**\n\t * Starts an outgoing transaction to request a list of gxs group IDs that we want the peer to transfer to us.\n\t *\n\t * @param peerConnection the peer\n\t * @param items          gxs group IDs\n\t * @param transactionId  the transaction ID\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void startOutgoingTransactionForGroupListRequest(PeerConnection peerConnection, List<GxsSyncGroupItem> items, int transactionId, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tvar transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_GROUP_LIST_REQUEST), items, items.size(), gxsRsService, OUTGOING);\n\t\tstartOutgoingTransaction(peerConnection, transaction, Instant.EPOCH);\n\t}\n\n\t/**\n\t * Starts an outgoing transaction to request a list of gxs message IDs that we want the peer to transfer to us.\n\t *\n\t * @param peerConnection the peer\n\t * @param items          gxs message IDs\n\t * @param transactionId  the transaction ID\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void startOutgoingTransactionForMessageListRequest(PeerConnection peerConnection, List<GxsSyncMessageItem> items, int transactionId, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tvar transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_MESSAGE_LIST_REQUEST), items, items.size(), gxsRsService, OUTGOING);\n\t\tstartOutgoingTransaction(peerConnection, transaction, Instant.EPOCH);\n\t}\n\n\t/**\n\t * Starts an outgoing transaction to respond with a list of gxs group IDs that we have and their update time.\n\t *\n\t * @param peerConnection the peer\n\t * @param items          gxs group IDs\n\t * @param update         the last update of the list\n\t * @param transactionId  the transaction ID\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void startOutgoingTransactionForGroupListResponse(PeerConnection peerConnection, List<GxsSyncGroupItem> items, Instant update, int transactionId, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tvar transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_GROUP_LIST_RESPONSE), items, items.size(), gxsRsService, OUTGOING);\n\t\tstartOutgoingTransaction(peerConnection, transaction, update);\n\t}\n\n\t/**\n\t * Starts an outgoing transaction to respond with a list of gxs message IDs that we have and their update time.\n\t *\n\t * @param peerConnection the peer\n\t * @param items          gxs message IDs\n\t * @param update         the last update of the list\n\t * @param transactionId  the transaction ID\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void startOutgoingTransactionForMessageListResponse(PeerConnection peerConnection, List<GxsSyncMessageItem> items, Instant update, int transactionId, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tvar transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_MESSAGE_LIST_RESPONSE), items, items.size(), gxsRsService, OUTGOING);\n\t\tstartOutgoingTransaction(peerConnection, transaction, update);\n\t}\n\n\t/**\n\t * Starts an outgoing transaction to transfer gxs groups.\n\t *\n\t * @param peerConnection the peer\n\t * @param items          gxs groups\n\t * @param update         the last update of the groups\n\t * @param transactionId  the transaction ID\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void startOutgoingTransactionForGroupTransfer(PeerConnection peerConnection, List<GxsTransferGroupItem> items, Instant update, int transactionId, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tvar transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_GROUPS), items, items.size(), gxsRsService, OUTGOING);\n\t\tstartOutgoingTransaction(peerConnection, transaction, update);\n\t}\n\n\t/**\n\t * Starts an outgoing transaction to transfer gxs messages.\n\t *\n\t * @param peerConnection the peer\n\t * @param items          gxs messages\n\t * @param update         the last update of the groups\n\t * @param transactionId  the transaction ID\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void startOutgoingTransactionForMessageTransfer(PeerConnection peerConnection, List<GxsTransferMessageItem> items, Instant update, int transactionId, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tvar transaction = new Transaction<>(transactionId, EnumSet.of(START, TYPE_MESSAGES), items, items.size(), gxsRsService, OUTGOING);\n\t\tstartOutgoingTransaction(peerConnection, transaction, update);\n\n\t}\n\n\t/**\n\t * Processes an incoming transactions (incoming, confirmation of outgoing, success confirmation).\n\t *\n\t * @param peerConnection the peer\n\t * @param item           a transaction item (contains transaction type, timestamp and total number of items)\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void processIncomingTransaction(PeerConnection peerConnection, GxsTransactionItem item, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tif (item.getFlags().contains(START))\n\t\t{\n\t\t\t//  This is an incoming connection\n\t\t\tlog.debug(\"Received INCOMING transaction {} from peer {}, sending back ACK\", item, peerConnection);\n\t\t\tSet<TransactionFlags> transactionFlags = EnumSet.copyOf(item.getFlags());\n\t\t\ttransactionFlags.retainAll(TransactionFlags.ofTypes());\n\t\t\ttransactionFlags.add(START_ACKNOWLEDGE);\n\n\t\t\tvar transaction = new Transaction<>(item.getTransactionId(), transactionFlags, new ArrayList<>(), item.getItemCount(), gxsRsService, INCOMING);\n\t\t\ttransaction.setUpdated(Instant.ofEpochSecond(item.getUpdateTimestamp()));\n\t\t\taddTransaction(peerConnection, transaction, INCOMING);\n\n\t\t\tvar readyTransactionItem = new GxsTransactionItem(\n\t\t\t\t\ttransactionFlags,\n\t\t\t\t\titem.getTransactionId()\n\t\t\t);\n\t\t\tpeerConnectionManager.writeItem(peerConnection, readyTransactionItem, transaction.getService());\n\t\t\ttransaction.setState(State.RECEIVING);\n\t\t}\n\t\telse if (item.getFlags().contains(START_ACKNOWLEDGE))\n\t\t{\n\t\t\t// This is the confirmation by the peer of our outgoing connection\n\t\t\tlog.debug(\"Confirmation of OUTGOING transaction {} from peer {}, sending items...\", item, peerConnection);\n\t\t\tvar transaction = getTransaction(peerConnection, item.getTransactionId(), OUTGOING);\n\t\t\ttransaction.setState(State.SENDING);\n\n\t\t\tlog.debug(\"{} items to go\", transaction.getItems().size());\n\t\t\ttransaction.getItems().forEach(gxsExchange -> peerConnectionManager.writeItem(peerConnection, gxsExchange, transaction.getService()));\n\t\t\tlog.debug(\"done\");\n\n\t\t\ttransaction.setState(State.WAITING_CONFIRMATION);\n\t\t}\n\t\telse if (item.getFlags().contains(END_SUCCESS))\n\t\t{\n\t\t\t// The peer confirms success\n\t\t\tlog.debug(\"Got END_SUCCESS transaction {} from peer {}, removing the transaction\", item, peerConnection);\n\t\t\tvar transaction = getTransaction(peerConnection, item.getTransactionId(), OUTGOING);\n\t\t\ttransaction.setState(State.COMPLETED);\n\t\t\tremoveTransaction(peerConnection, transaction);\n\t\t}\n\t}\n\n\t/**\n\t * Adds an incoming item to an existing transaction.\n\t *\n\t * @param peerConnection the peer\n\t * @param item           the item to add to the transaction\n\t * @param gxsRsService   the service the transaction is bound to\n\t */\n\tpublic void addIncomingItemToTransaction(PeerConnection peerConnection, GxsExchange item, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> gxsRsService)\n\t{\n\t\tlog.trace(\"Adding transaction item: {}\", item);\n\t\tvar transaction = getTransaction(peerConnection, item.getTransactionId(), INCOMING);\n\t\ttransaction.addItem(item);\n\n\t\tif (transaction.hasAllItems())\n\t\t{\n\t\t\tlog.debug(\"Received all items of {}, sending COMPLETED\", transaction);\n\t\t\ttransaction.setState(State.COMPLETED);\n\t\t\tvar successTransactionItem = new GxsTransactionItem(\n\t\t\t\t\tEnumSet.of(END_SUCCESS),\n\t\t\t\t\ttransaction.getId()\n\t\t\t);\n\t\t\tpeerConnectionManager.writeItem(peerConnection, successTransactionItem, transaction.getService());\n\n\t\t\tgxsRsService.processItems(peerConnection, transaction); // XXX: how will processItems() know what the items are? should the transaction have something to know that? yes, the flag...\n\n\t\t\tremoveTransaction(peerConnection, transaction);\n\t\t\t// XXX: in the case that interest us, GxsIdService would call requestGxsGroups()\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onPeerDisconnectedEvent(PeerDisconnectedEvent event)\n\t{\n\t\tincomingTransactions.remove(event.locationIdentifier());\n\t\toutgoingTransactions.remove(event.locationIdentifier());\n\t}\n\n\tprivate void addTransaction(PeerConnection peerConnection, Transaction<?> transaction, Direction direction)\n\t{\n\t\tMap<LocationIdentifier, Map<Integer, Transaction<?>>> transactionList = switch (direction)\n\t\t{\n\t\t\tcase OUTGOING -> outgoingTransactions;\n\t\t\tcase INCOMING -> incomingTransactions;\n\t\t};\n\n\t\tvar transactionMap = transactionList.computeIfAbsent(peerConnection.getLocation().getLocationIdentifier(), _ -> new HashMap<>());\n\t\tif (transactionMap.put(transaction.getId(), transaction) != null && direction == OUTGOING)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Transaction \" + transaction.getId() + \" (OUTGOING) for peer \" + peerConnection + \" already exists. Should not happen (tm)\");\n\t\t}\n\t}\n\n\tprivate Transaction<?> getTransaction(PeerConnection peerConnection, int id, Direction direction)\n\t{\n\t\tvar locationIdentifier = peerConnection.getLocation().getLocationIdentifier();\n\n\t\tvar transactionMap = direction == INCOMING ? incomingTransactions.get(locationIdentifier) : outgoingTransactions.get(locationIdentifier);\n\t\tif (transactionMap == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"No existing transaction for peer \" + peerConnection);\n\t\t}\n\t\tvar transaction = transactionMap.get(id);\n\t\tif (transaction == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"No existing transaction for peer \" + peerConnection);\n\t\t}\n\t\tif (transaction.hasTimedOut())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Transaction timed out for peer \" + peerConnection);\n\t\t}\n\t\treturn transaction;\n\t}\n\n\tprivate void removeTransaction(PeerConnection peerConnection, Transaction<?> transaction)\n\t{\n\t\tvar locationIdentifier = peerConnection.getLocation().getLocationIdentifier();\n\n\t\tvar transactionMap = transaction.getDirection() == INCOMING ? incomingTransactions.get(locationIdentifier) : outgoingTransactions.get(locationIdentifier);\n\t\tif (transactionMap == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"No existing transaction for removal for peer \" + peerConnection);\n\t\t}\n\t\tif (!transactionMap.remove(transaction.getId(), transaction))\n\t\t{\n\t\t\tthrow new IllegalStateException(\"No existing transaction for removal for peer \" + peerConnection);\n\t\t}\n\t}\n\n\tprivate void startOutgoingTransaction(PeerConnection peerConnection, Transaction<? extends GxsExchange> transaction, Instant update)\n\t{\n\t\tlog.debug(\"Starting outgoing transaction {} with peer {}\", transaction, peerConnection);\n\t\taddTransaction(peerConnection, transaction, OUTGOING);\n\n\t\tvar startTransactionItem = new GxsTransactionItem(\n\t\t\t\ttransaction.getTransactionFlags(),\n\t\t\t\ttransaction.getItems().size(),\n\t\t\t\t(int) update.getEpochSecond(),\n\t\t\t\ttransaction.getId());\n\n\t\tpeerConnectionManager.writeItem(peerConnection, startTransactionItem, transaction.getService());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/Transaction.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.gxs.item.GxsExchange;\nimport io.xeres.app.xrs.service.gxs.item.TransactionFlags;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Set;\n\n/**\n * A Transaction is a way to group multiple items of the same type that have the same transaction id. Transactions can be outgoing or incoming and have\n * different states. Once a transaction is complete, its items can be accessed.\n *\n * @param <T> the GxsExchange type. GxsExchange for incoming transactions and a subclass for outgoing transactions.\n * @see GxsTransactionManager\n */\npublic class Transaction<T extends GxsExchange>\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(Transaction.class);\n\n\tpublic enum State\n\t{\n\t\tSTARTING,\n\t\tRECEIVING,\n\t\tSENDING,\n\t\tCOMPLETED,\n\t\tFAILED,\n\t\tWAITING_CONFIRMATION\n\t}\n\n\tpublic enum Direction\n\t{\n\t\tINCOMING,\n\t\tOUTGOING\n\t}\n\n\tpublic static final Duration TRANSACTION_TIMEOUT = Duration.ofSeconds(2000);\n\n\tprivate final int id;\n\tprivate State state;\n\tprivate final Direction direction;\n\tprivate final Set<TransactionFlags> transactionFlags;\n\tprivate final Instant start;\n\tprivate final Duration timeout;\n\tprivate final List<T> items;\n\tprivate final int itemCount;\n\tprivate final GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> service;\n\tprivate Instant updated;\n\n\tTransaction(int id, Set<TransactionFlags> transactionFlags, List<T> items, int itemCount, GxsRsService<? extends GxsGroupItem, ? extends GxsMessageItem> service, Direction direction)\n\t{\n\t\tthis.id = id;\n\t\tthis.transactionFlags = transactionFlags;\n\t\tthis.items = items;\n\t\tthis.itemCount = itemCount;\n\t\ttimeout = TRANSACTION_TIMEOUT;\n\t\tthis.service = service;\n\t\tstate = direction == Direction.OUTGOING ? State.WAITING_CONFIRMATION : State.STARTING;\n\t\tthis.direction = direction;\n\t\tstart = Instant.now();\n\t}\n\n\tpublic int getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic State getState()\n\t{\n\t\treturn state;\n\t}\n\n\tpublic Direction getDirection()\n\t{\n\t\treturn direction;\n\t}\n\n\tpublic void setState(State state)\n\t{\n\t\tthis.state = state;\n\t}\n\n\tpublic List<T> getItems()\n\t{\n\t\treturn items;\n\t}\n\n\t@SuppressWarnings(\"unchecked\")\n\tpublic void addItem(GxsExchange item)\n\t{\n\t\titems.add((T) item);\n\t}\n\n\tpublic RsService getService()\n\t{\n\t\treturn service;\n\t}\n\n\tpublic boolean hasAllItems()\n\t{\n\t\tlog.trace(\"expected number of items: {}, current number of items: {}\", itemCount, items.size());\n\t\treturn itemCount == items.size();\n\t}\n\n\tpublic boolean hasTimedOut()\n\t{\n\t\treturn start.plus(timeout).isBefore(Instant.now());\n\t}\n\n\tpublic Set<TransactionFlags> getTransactionFlags()\n\t{\n\t\treturn transactionFlags;\n\t}\n\n\tpublic Instant getUpdated()\n\t{\n\t\treturn updated;\n\t}\n\n\tpublic void setUpdated(Instant updated)\n\t{\n\t\tthis.updated = updated;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"Transaction{\" +\n\t\t\t\t\"id=\" + id +\n\t\t\t\t\", flags=\" + transactionFlags +\n\t\t\t\t\", state=\" + state +\n\t\t\t\t\", type=\" + direction +\n\t\t\t\t\", itemCount=\" + itemCount +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/doc-files/transaction.puml",
    "content": "@startuml\nrnote over Juergen: STARTING\nJuergen -> Heike : START\nrnote over Heike : RECEIVING\nJuergen <- Heike : START_ACKNOWLEDGE\nrnote over Juergen: SENDING\nJuergen -> Heike: Sending data\nJuergen -> Heike: Sending data\nJuergen -> Heike: ...\nJuergen -> Heike: Sending data\nrnote over Juergen: WAITING_CONFIRMATION\nrnote over Heike: COMPLETED\nHeike -> Juergen: END_SUCCESS\nrnote over Juergen: COMPLETED\n@enduml\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/doc-files/transfer.puml",
    "content": "@startuml\nJuergen -> Heike : GxsSyncGroupRequestItem\nrnote over Heike : onAvailableGroupListRequest()\nJuergen <- Heike : GROUP_LIST_RESPONSE //List<GxsSyncGroupItems>//\nrnote over Juergen: onAvailableGroupListResponse()\nJuergen -> Heike: GROUP_LIST_REQUEST //List<GxsSyncGroupItems>//\nrnote over Heike: onGroupListRequest()\nHeike -> Juergen: TRANSFER //List<GxsTransferGroupItem>//\nrnote over Juergen: onGroupReceived()\nrnote over Juergen: onGroupReceived()\nrnote over Juergen: onGroupReceived()\nrnote over Juergen: ...\nrnote over Juergen: onGroupsSaved()\n@enduml"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/DynamicServiceType.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\n/**\n * This interface is used for items that don't have an intrinsic service type, because for example\n * they're shared between multiple services (Gxs, ...).\n */\npublic interface DynamicServiceType\n{\n\tint getServiceType();\n\n\tvoid setServiceType(int serviceType);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsExchange.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\npublic abstract class GxsExchange extends Item implements DynamicServiceType\n{\n\t@RsSerialized\n\tprivate int transactionId;\n\n\tprivate int serviceType;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\t/**\n\t * GxsExchange items are shared between GxsServices. Make sure this is set by whatever creates the item.\n\t *\n\t * @param serviceType the service type\n\t */\n\t@Override\n\tpublic void setServiceType(int serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic int getTransactionId()\n\t{\n\t\treturn transactionId;\n\t}\n\n\tpublic void setTransactionId(int transactionId)\n\t{\n\t\tthis.transactionId = transactionId;\n\t}\n\n\t@Override\n\tpublic GxsExchange clone()\n\t{\n\t\treturn (GxsExchange) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsExchange{\" +\n\t\t\t\t\"transactionId=\" + transactionId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncGroupItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.GxsId;\n\n/**\n * Item used to send the list of new groups that we have for a peer.\n */\npublic class GxsSyncGroupItem extends GxsExchange\n{\n\tpublic static final byte REQUEST = 0x1;\n\tpublic static final byte RESPONSE = 0x2;\n\n\t@RsSerialized\n\tprivate byte flags;\n\n\t@RsSerialized\n\tprivate GxsId gxsId;\n\n\t@RsSerialized\n\tprivate int publishTimestamp;\n\n\t@RsSerialized\n\tprivate GxsId authorGxsId;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsSyncGroupItem()\n\t{\n\t}\n\n\tpublic GxsSyncGroupItem(byte flags, GxsGroupItem groupItem, int transactionId)\n\t{\n\t\tthis.flags = flags;\n\t\tpublishTimestamp = (int) groupItem.getPublished().getEpochSecond();\n\t\tgxsId = groupItem.getGxsId();\n\t\tauthorGxsId = groupItem.getAuthorGxsId();\n\t\tsetTransactionId(transactionId);\n\t}\n\n\tpublic GxsSyncGroupItem(byte flags, GxsId gxsId, int transactionId)\n\t{\n\t\tthis.flags = flags;\n\t\tthis.gxsId = gxsId;\n\t\tsetTransactionId(transactionId);\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic int getPublishTimestamp()\n\t{\n\t\treturn publishTimestamp;\n\t}\n\n\t@Override\n\tpublic GxsSyncGroupItem clone()\n\t{\n\t\treturn (GxsSyncGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsSyncGroupItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", publishTimestamp=\" + publishTimestamp +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", authorGxsId=\" + authorGxsId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncGroupRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport java.time.Instant;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_HASH_SHA1;\n\n/**\n * Item used to request new group list from a peer. Sent each minute with\n * the last syncing time.\n */\npublic class GxsSyncGroupRequestItem extends GxsExchange\n{\n\t@SuppressWarnings(\"unused\")\n\t@RsSerialized\n\tprivate byte flags; // unused\n\n\t@SuppressWarnings(\"unused\")\n\t@RsSerialized\n\tprivate int limit; // unused\n\n\t@SuppressWarnings(\"unused\")\n\t@RsSerialized(tlvType = STR_HASH_SHA1)\n\tprivate String syncHash; // unused. This is old stuff where it used to transfer files instead of building tunnels\n\n\t@RsSerialized\n\tprivate int lastUpdated; // last group update\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsSyncGroupRequestItem()\n\t{\n\t}\n\n\tpublic GxsSyncGroupRequestItem(Instant lastUpdated)\n\t{\n\t\tthis.lastUpdated = (int) lastUpdated.getEpochSecond();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\tpublic int getLastUpdated()\n\t{\n\t\treturn lastUpdated;\n\t}\n\n\tpublic void setLastUpdated(int lastUpdated)\n\t{\n\t\tthis.lastUpdated = lastUpdated;\n\t}\n\n\t@Override\n\tpublic GxsSyncGroupRequestItem clone()\n\t{\n\t\treturn (GxsSyncGroupRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsSyncGroupRequestItem{\" +\n\t\t\t\t\"lastUpdated=\" + lastUpdated +\n\t\t\t\t\", super=\" + super.toString() +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncGroupStatsItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.GxsId;\n\n/**\n * This item is used to request statistics about a group.\n * Note that it doesn't extend GxsExchange because it doesn't use transactions.\n */\npublic class GxsSyncGroupStatsItem extends Item implements DynamicServiceType\n{\n\t@RsSerialized\n\tprivate RequestType requestType;\n\n\t@RsSerialized\n\tprivate GxsId gxsId;\n\n\t@RsSerialized\n\tprivate int numberOfPosts;\n\n\t@RsSerialized\n\tprivate int lastPostTimestamp;\n\n\tprivate int serviceType;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsSyncGroupStatsItem()\n\t{\n\t}\n\n\tpublic GxsSyncGroupStatsItem(RequestType requestType, GxsId gxsId)\n\t{\n\t\tthis(requestType, gxsId, 0, 0);\n\t}\n\n\tpublic GxsSyncGroupStatsItem(RequestType requestType, GxsId gxsId, int lastPostTimestamp, int numberOfPosts)\n\t{\n\t\tthis.requestType = requestType;\n\t\tthis.gxsId = gxsId;\n\t\tthis.lastPostTimestamp = lastPostTimestamp;\n\t\tthis.numberOfPosts = numberOfPosts;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority(); // XXX: not sure...\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\t@Override\n\tpublic void setServiceType(int serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\tpublic RequestType getRequestType()\n\t{\n\t\treturn requestType;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic int getNumberOfPosts()\n\t{\n\t\treturn numberOfPosts;\n\t}\n\n\tpublic int getLastPostTimestamp()\n\t{\n\t\treturn lastPostTimestamp;\n\t}\n\n\t@Override\n\tpublic GxsSyncGroupStatsItem clone()\n\t{\n\t\treturn (GxsSyncGroupStatsItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsSyncGroupStatsItem{\" +\n\t\t\t\t\"requestType=\" + requestType +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", numberOfPosts=\" + numberOfPosts +\n\t\t\t\t\", lastPostTimestamp=\" + lastPostTimestamp +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncMessageItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\npublic class GxsSyncMessageItem extends GxsExchange\n{\n\tpublic static final byte REQUEST = 0x1;\n\tpublic static final byte RESPONSE = 0x2;\n\n\t@RsSerialized\n\tprivate byte flags;\n\n\t@RsSerialized\n\tprivate GxsId gxsId;\n\n\t@RsSerialized\n\tprivate MsgId msgId;\n\n\t@RsSerialized\n\tprivate GxsId authorGxsId;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsSyncMessageItem()\n\t{\n\t}\n\n\tpublic GxsSyncMessageItem(byte flags, GxsMessageItem messageItem, int transactionId)\n\t{\n\t\tthis.flags = flags;\n\t\tgxsId = messageItem.getGxsId();\n\t\tmsgId = messageItem.getMsgId();\n\t\tauthorGxsId = messageItem.getAuthorGxsId();\n\t\tsetTransactionId(transactionId);\n\t}\n\n\tpublic GxsSyncMessageItem(byte flags, GxsId gxsId, MsgId msgId, int transactionId)\n\t{\n\t\tthis.flags = flags;\n\t\tthis.gxsId = gxsId;\n\t\tthis.msgId = msgId;\n\t\tsetTransactionId(transactionId);\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 8;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\t@Override\n\tpublic GxsSyncMessageItem clone()\n\t{\n\t\treturn (GxsSyncMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsSyncMessageItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", msgId=\" + msgId +\n\t\t\t\t\", authorGxsId=\" + authorGxsId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncMessageRequestItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Duration;\nimport java.time.Instant;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_HASH_SHA1;\n\n/**\n * Item used to request messages from a peer's group.\n */\npublic class GxsSyncMessageRequestItem extends GxsExchange\n{\n\tpublic static final byte USE_HASHED_GROUP_ID = 0x2; // Use this when implementing circles (avoids someone outside the circle to know to which group we're subscribed)\n\n\t@RsSerialized\n\tprivate byte flags;\n\n\t@RsSerialized\n\tprivate int limit; // how far back to sync data\n\n\t@RsSerialized(tlvType = STR_HASH_SHA1)\n\tprivate String syncHash;\n\n\t@RsSerialized\n\tprivate GxsId gxsId;\n\n\t@RsSerialized\n\tprivate int lastUpdated;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsSyncMessageRequestItem()\n\t{\n\t}\n\n\tpublic GxsSyncMessageRequestItem(GxsId gxsId, Instant lastUpdated, Duration limit)\n\t{\n\t\tthis.gxsId = gxsId;\n\t\tthis.lastUpdated = (int) lastUpdated.getEpochSecond();\n\t\tthis.limit = (int) Instant.now().minus(limit).getEpochSecond();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 16;\n\t}\n\n\tpublic int getLimit()\n\t{\n\t\treturn limit;\n\t}\n\n\tpublic void setLimit(int limit)\n\t{\n\t\tthis.limit = limit;\n\t}\n\n\tpublic String getSyncHash()\n\t{\n\t\treturn syncHash;\n\t}\n\n\tpublic void setSyncHash(String syncHash)\n\t{\n\t\tthis.syncHash = syncHash;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic int getLastUpdated()\n\t{\n\t\treturn lastUpdated;\n\t}\n\n\tpublic void setLastUpdated(int lastUpdated)\n\t{\n\t\tthis.lastUpdated = lastUpdated;\n\t}\n\n\t@Override\n\tpublic GxsSyncMessageRequestItem clone()\n\t{\n\t\treturn (GxsSyncMessageRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsSyncMessageRequestItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", syncHash='\" + syncHash + '\\'' +\n\t\t\t\t\", lastUpdated=\" + Instant.ofEpochSecond(lastUpdated) +\n\t\t\t\t\", limit=\" + Instant.ofEpochSecond(limit) +\n\t\t\t\t\", super=\" + super.toString() +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsSyncNotifyItem.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\n\n/**\n * Item used to tell the peer that there have been changes, and it should request them immediately without\n * waiting for the next sync delay.\n */\npublic class GxsSyncNotifyItem extends Item implements DynamicServiceType\n{\n\tprivate int serviceType;\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 144;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority(); // XXX: not sure...\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\t@Override\n\tpublic void setServiceType(int serviceType)\n\t{\n\t\tthis.serviceType = serviceType;\n\t}\n\n\t@Override\n\tpublic GxsSyncNotifyItem clone()\n\t{\n\t\treturn (GxsSyncNotifyItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsSyncNotifyItem {}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsTransactionItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.app.xrs.serialization.FieldSize;\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport java.util.Set;\n\n/**\n * This item is used to make a transaction, which guarantees\n * that a collection of items have been received.\n */\npublic class GxsTransactionItem extends GxsExchange\n{\n\t@RsSerialized(fieldSize = FieldSize.SHORT)\n\tprivate Set<TransactionFlags> flags;\n\n\t@RsSerialized\n\tprivate int itemCount;\n\n\t@RsSerialized\n\tprivate int updateTimestamp;\n\n\tprivate int timestamp; // Not serialized, used for timeout detection (XXX: I don't think I need it)\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsTransactionItem()\n\t{\n\t}\n\n\tpublic GxsTransactionItem(Set<TransactionFlags> flags, int itemCount, int updateTimestamp, int transactionId)\n\t{\n\t\tthis.flags = flags;\n\t\tthis.itemCount = itemCount;\n\t\tthis.updateTimestamp = updateTimestamp;\n\t\tsetTransactionId(transactionId);\n\t}\n\n\tpublic GxsTransactionItem(Set<TransactionFlags> flags, int transactionId)\n\t{\n\t\tthis.flags = flags;\n\t\tsetTransactionId(transactionId);\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 64;\n\t}\n\n\tpublic Set<TransactionFlags> getFlags()\n\t{\n\t\treturn flags;\n\t}\n\n\tpublic int getItemCount()\n\t{\n\t\treturn itemCount;\n\t}\n\n\tpublic int getUpdateTimestamp()\n\t{\n\t\treturn updateTimestamp;\n\t}\n\n\tpublic int getTimestamp()\n\t{\n\t\treturn timestamp;\n\t}\n\n\t@Override\n\tpublic GxsTransactionItem clone()\n\t{\n\t\treturn (GxsTransactionItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTransactionItem{\" +\n\t\t\t\t\"transactionFlag=\" + flags +\n\t\t\t\t\", itemCount=\" + itemCount +\n\t\t\t\t\", updateTimestamp=\" + updateTimestamp +\n\t\t\t\t\", super=\" + super.toString() +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsTransferGroupItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.xrs.item.ItemHeader;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.EnumSet;\nimport java.util.Set;\n\n/**\n * This is used to transfer group data within transactions. This is usually\n * backed by a GxsGroupItem which can serialize directly when not used in transactions.\n */\npublic class GxsTransferGroupItem extends GxsExchange implements RsSerializable\n{\n\tprivate byte position; // used for splitting up groups\n\tprivate GxsId gxsId;\n\tprivate byte[] group; // actual group data; the service specific data (ie. avatar, etc...))\n\tprivate byte[] meta; // binary data for the group meta that is sent to our friends\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsTransferGroupItem()\n\t{\n\t}\n\n\tpublic GxsTransferGroupItem(GxsGroupItem gxsGroupItem, int transactionId, RsServiceType serviceType)\n\t{\n\t\tgxsId = gxsGroupItem.getGxsId();\n\t\tsetTransactionId(transactionId);\n\t\tsetServiceType(serviceType.getType());\n\n\t\tvar groupBuf = Unpooled.buffer();\n\t\tvar itemHeader = new ItemHeader(groupBuf, getServiceType(), gxsGroupItem.getSubType());\n\t\titemHeader.writeHeader();\n\t\tvar groupSize = gxsGroupItem.writeDataObject(groupBuf, EnumSet.noneOf(SerializationFlags.class));\n\t\titemHeader.writeSize(groupSize);\n\n\t\tvar metaBuf = Unpooled.buffer();\n\t\tgxsGroupItem.writeMetaObject(metaBuf, EnumSet.noneOf(SerializationFlags.class));\n\n\t\tgroup = getArray(groupBuf);\n\t\tmeta = getArray(metaBuf);\n\n\t\tgroupBuf.release();\n\t\tmetaBuf.release();\n\t}\n\n\tpublic void toGxsGroupItem(GxsGroupItem gxsGroupItem)\n\t{\n\t\tvar buf = Unpooled.copiedBuffer(meta, group);\n\n\t\tgxsGroupItem.readMetaObject(buf);\n\t\tItemHeader.readHeader(buf, getServiceType(), gxsGroupItem.getSubType());\n\t\tgxsGroupItem.readDataObject(buf);\n\n\t\tbuf.release();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 4;\n\t}\n\n\tpublic byte getPosition()\n\t{\n\t\treturn position;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic byte[] getGroup()\n\t{\n\t\treturn group;\n\t}\n\n\tpublic byte[] getMeta()\n\t{\n\t\treturn meta;\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = Serializer.serialize(buf, getTransactionId());\n\t\tsize += Serializer.serialize(buf, position);\n\t\tsize += Serializer.serialize(buf, gxsId, GxsId.class);\n\t\tsize += Serializer.serializeTlvBinary(buf, getServiceType(), group);\n\t\tsize += Serializer.serializeTlvBinary(buf, getServiceType(), meta);\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tsetTransactionId(Serializer.deserializeInt(buf));\n\t\tposition = Serializer.deserializeByte(buf);\n\t\tgxsId = (GxsId) Serializer.deserializeIdentifier(buf, GxsId.class);\n\t\tgroup = Serializer.deserializeTlvBinary(buf, getServiceType());\n\t\tmeta = Serializer.deserializeTlvBinary(buf, getServiceType());\n\t}\n\n\t@Override\n\tpublic GxsTransferGroupItem clone()\n\t{\n\t\treturn (GxsTransferGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTransferGroupItem{\" +\n\t\t\t\t\"position=\" + position +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t'}';\n\t}\n\n\tprivate static byte[] getArray(ByteBuf buf)\n\t{\n\t\tvar out = new byte[buf.writerIndex()];\n\t\tbuf.readBytes(out);\n\t\treturn out;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/GxsTransferMessageItem.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.xrs.item.ItemHeader;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.EnumSet;\nimport java.util.Set;\n\npublic class GxsTransferMessageItem extends GxsExchange implements RsSerializable\n{\n\tprivate byte position;\n\tprivate GxsId gxsId;\n\tprivate MsgId msgId;\n\tprivate byte[] message;\n\tprivate byte[] meta;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsTransferMessageItem()\n\t{\n\t}\n\n\tpublic GxsTransferMessageItem(GxsMessageItem gxsMessageItem, int transactionId, RsServiceType serviceType)\n\t{\n\t\tgxsId = gxsMessageItem.getGxsId();\n\t\tmsgId = gxsMessageItem.getMsgId();\n\t\tsetTransactionId(transactionId);\n\t\tsetServiceType(serviceType.getType());\n\n\t\t// XXX: sign the message. add the signature stuff to GxsMessageItem\n\t\tvar messageBuf = Unpooled.buffer();\n\t\tvar itemHeader = new ItemHeader(messageBuf, getServiceType(), gxsMessageItem.getSubType());\n\t\titemHeader.writeHeader();\n\t\tvar messageSize = gxsMessageItem.writeDataObject(messageBuf, EnumSet.noneOf(SerializationFlags.class));\n\t\titemHeader.writeSize(messageSize);\n\n\t\tvar metaBuf = Unpooled.buffer();\n\t\tgxsMessageItem.writeMetaObject(metaBuf, EnumSet.noneOf(SerializationFlags.class));\n\n\t\tmessage = getArray(messageBuf);\n\t\tmeta = getArray(metaBuf);\n\n\t\tmessageBuf.release();\n\t\tmetaBuf.release();\n\t}\n\n\tpublic GxsMessageItem toGxsMessageItem(GxsMessageItem gxsMessageItem)\n\t{\n\t\tvar buf = Unpooled.copiedBuffer(meta, message);\n\n\t\tgxsMessageItem.readMetaObject(buf);\n\t\tItemHeader.readHeader(buf, getServiceType(), gxsMessageItem.getSubType());\n\t\tgxsMessageItem.readDataObject(buf);\n\n\t\tbuf.release();\n\t\treturn gxsMessageItem;\n\t}\n\n\tpublic int getMessageType()\n\t{\n\t\tvar buf = Unpooled.wrappedBuffer(message);\n\t\tvar subType = ItemHeader.getSubType(buf);\n\t\tbuf.release();\n\t\treturn subType;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 32;\n\t}\n\n\tpublic byte getPosition()\n\t{\n\t\treturn position;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\tpublic byte[] getMessage()\n\t{\n\t\treturn message;\n\t}\n\n\tpublic byte[] getMeta()\n\t{\n\t\treturn meta;\n\t}\n\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = Serializer.serialize(buf, getTransactionId());\n\t\tsize += Serializer.serialize(buf, position);\n\t\tsize += Serializer.serialize(buf, msgId, MsgId.class);\n\t\tsize += Serializer.serialize(buf, gxsId, GxsId.class);\n\t\tsize += Serializer.serializeTlvBinary(buf, getServiceType(), message);\n\t\tsize += Serializer.serializeTlvBinary(buf, getServiceType(), meta);\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tsetTransactionId(Serializer.deserializeInt(buf));\n\t\tposition = Serializer.deserializeByte(buf);\n\t\tmsgId = (MsgId) Serializer.deserializeIdentifier(buf, MsgId.class);\n\t\tgxsId = (GxsId) Serializer.deserializeIdentifier(buf, GxsId.class);\n\t\tmessage = Serializer.deserializeTlvBinary(buf, getServiceType());\n\t\tmeta = Serializer.deserializeTlvBinary(buf, getServiceType());\n\t}\n\n\t@Override\n\tpublic GxsTransferMessageItem clone()\n\t{\n\t\treturn (GxsTransferMessageItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTransferMessageItem{\" +\n\t\t\t\t\"position=\" + position +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", msgId=\" + msgId +\n\t\t\t\t'}';\n\t}\n\n\tprivate static byte[] getArray(ByteBuf buf)\n\t{\n\t\tvar out = new byte[buf.writerIndex()];\n\t\tbuf.readBytes(out);\n\t\treturn out;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/RequestType.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\npublic enum RequestType\n{\n\tNONE, // Unused\n\tREQUEST,\n\tRESPONSE\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxs/item/TransactionFlags.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport java.util.EnumSet;\nimport java.util.Set;\n\npublic enum TransactionFlags\n{\n\t// States\n\tSTART, // FLAG_BEGIN_P1\n\tSTART_ACKNOWLEDGE, // FLAG_BEGIN_P2\n\tEND_SUCCESS, // FLAG_END_SUCCESS\n\tCANCEL, // FLAG_CANCEL (not used it seems)\n\tEND_FAIL_NUM, // FLAG_END_FAIL_NUM (not used it seems)\n\tEND_FAIL_TIMEOUT, // FLAG_END_FAIL_TIMEOUT (not used it seems)\n\tEND_FAIL_FULL, // FLAG_END_FAIL_FULL (not used it seems)\n\tUNUSED,\n\t// Types\n\tTYPE_GROUP_LIST_RESPONSE, // FLAG_TYPE_GRP_LIST_RESP\n\tTYPE_MESSAGE_LIST_RESPONSE, // FLAG_TYPE_MSG_LIST_RESP\n\tTYPE_GROUP_LIST_REQUEST, // FLAG_TYPE_GRP_LIST_REQ\n\tTYPE_MESSAGE_LIST_REQUEST, // FLAG_TYPE_MSG_LIST_REQ\n\tTYPE_GROUPS, // FLAG_TYPE_GRPS\n\tTYPE_MESSAGES, // FLAG_TYPE_MESSAGES\n\tTYPE_ENCRYPTED_DATA; // FLAG_TYPE_ENCRYPTED_DATA (not used it seems)\n\n\tpublic static Set<TransactionFlags> ofStates()\n\t{\n\t\treturn EnumSet.of(\n\t\t\t\tSTART,\n\t\t\t\tSTART_ACKNOWLEDGE,\n\t\t\t\tEND_SUCCESS,\n\t\t\t\tCANCEL,\n\t\t\t\tEND_FAIL_NUM,\n\t\t\t\tEND_FAIL_TIMEOUT,\n\t\t\t\tEND_FAIL_FULL\n\t\t);\n\t}\n\n\tpublic static Set<TransactionFlags> ofTypes()\n\t{\n\t\treturn EnumSet.of(\n\t\t\t\tTYPE_GROUP_LIST_RESPONSE,\n\t\t\t\tTYPE_MESSAGE_LIST_RESPONSE,\n\t\t\t\tTYPE_GROUP_LIST_REQUEST,\n\t\t\t\tTYPE_MESSAGE_LIST_REQUEST,\n\t\t\t\tTYPE_GROUPS,\n\t\t\t\tTYPE_MESSAGES,\n\t\t\t\tTYPE_ENCRYPTED_DATA);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/DestinationHash.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.util.SecureRandomUtils;\n\nfinal class DestinationHash\n{\n\tprivate DestinationHash()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Sha1Sum createRandomHash(GxsId to)\n\t{\n\t\tvar buf = new byte[Sha1Sum.LENGTH];\n\n\t\tSecureRandomUtils.nextBytes(buf);\n\t\tSystem.arraycopy(to.getBytes(), 0, buf, 4, GxsId.LENGTH);\n\n\t\treturn new Sha1Sum(buf);\n\t}\n\n\tpublic static GxsId getGxsIdFromHash(Sha1Sum hash)\n\t{\n\t\tvar buf = new byte[GxsId.LENGTH];\n\n\t\tSystem.arraycopy(hash.getBytes(), 4, buf, 0, GxsId.LENGTH);\n\t\treturn new GxsId(buf);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/GxsTunnelRsClient.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.RsServiceSlave;\nimport io.xeres.common.id.GxsId;\n\npublic interface GxsTunnelRsClient extends RsServiceSlave\n{\n\t/**\n\t * Called to initialize the gxs tunnel client.\n\t *\n\t * @param gxsTunnelRsService the {@link GxsTunnelRsService}. Is used to call service methods.\n\t * @return the service number\n\t */\n\tint onGxsTunnelInitialization(GxsTunnelRsService gxsTunnelRsService);\n\n\t/**\n\t * Called when data is received from the tunnel.\n\t *\n\t * @param tunnelId the tunnel id\n\t * @param data     the data\n\t */\n\tvoid onGxsTunnelDataReceived(Location tunnelId, byte[] data);\n\n\t/**\n\t * Called when a remote is requesting to establish a tunnel.\n\t *\n\t * @param sender the sender of the request\n\t * @param tunnelId the tunnel id\n\t * @param clientSide true if it's a client tunnel, false means it's a server tunnel\n\t * @return true if the tunnel is accepted\n\t */\n\tboolean onGxsTunnelDataAuthorization(GxsId sender, Location tunnelId, boolean clientSide);\n\n\t/**\n\t * Called when the tunnel status changes.\n\t *\n\t * @param tunnelId the tunnel id\n\t * @param destination the destination of the tunnel\n\t * @param status the new status\n\t */\n\tvoid onGxsTunnelStatusChanged(Location tunnelId, GxsId destination, GxsTunnelStatus status);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/GxsTunnelRsService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport io.xeres.app.crypto.aes.AES;\nimport io.xeres.app.crypto.dh.DiffieHellman;\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.crypto.hmac.sha1.Sha1HMac;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.xrs.common.SecurityKey;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemUtils;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceMaster;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.gxstunnel.item.*;\nimport io.xeres.app.xrs.service.turtle.TurtleRouter;\nimport io.xeres.app.xrs.service.turtle.TurtleRsClient;\nimport io.xeres.app.xrs.service.turtle.item.*;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.common.util.SecureRandomUtils;\nimport org.bouncycastle.util.BigIntegers;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\n\nimport javax.crypto.interfaces.DHPublicKey;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.security.KeyPair;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.PublicKey;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.atomic.AtomicLong;\nimport java.util.concurrent.locks.ReentrantLock;\n\nimport static io.xeres.app.xrs.common.SecurityKey.Flags.DISTRIBUTION_ADMIN;\nimport static io.xeres.app.xrs.common.SecurityKey.Flags.TYPE_PUBLIC_ONLY;\nimport static io.xeres.app.xrs.service.gxstunnel.GxsTunnelStatus.*;\nimport static io.xeres.app.xrs.service.gxstunnel.TunnelDhInfo.Status.HALF_KEY_DONE;\nimport static io.xeres.app.xrs.service.gxstunnel.TunnelDhInfo.Status.UNINITIALIZED;\nimport static io.xeres.common.protocol.xrs.RsServiceType.GXS_TUNNELS;\nimport static io.xeres.common.protocol.xrs.RsServiceType.TURTLE_ROUTER;\n\n/**\n * Generic tunnel service.\n * <p>\n * Services wanting to use it just need to implement {@link GxsTunnelRsClient}.\n * They can then request a tunnel from their identity to another identity and get a\n * handle (<i>tunnel id</i>). They can send data using that <i>tunnel id</i>. Several services can\n * use the same tunnel if the destination is the same. A <i>service id</i> is used to differentiate\n * between services.\n */\n@Component\npublic class GxsTunnelRsService extends RsService implements RsServiceMaster<GxsTunnelRsClient>, TurtleRsClient\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GxsTunnelRsService.class);\n\n\tprivate static final Duration TUNNEL_DELAY_BETWEEN_RESEND = Duration.ofSeconds(10);\n\n\tprivate static final Duration TUNNEL_KEEP_ALIVE_TIMEOUT = Duration.ofSeconds(6);\n\n\tprivate static final Duration TUNNEL_MANAGEMENT_DELAY = Duration.ofSeconds(2);\n\n\tprivate static final Duration TUNNEL_MESSAGES_DUPLICATE_DELAY = Duration.ofMinutes(10);\n\n\tprivate final AtomicLong counter = new AtomicLong();\n\n\tprivate final Map<Integer, GxsTunnelRsClient> clients = new HashMap<>();\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final IdentityService identityService;\n\n\tprivate ScheduledExecutorService executorService;\n\n\t/**\n\t * Current peers we can talk to. Key is a tunnel id.\n\t */\n\tprivate final Map<Location, TunnelPeerInfo> contacts = new ConcurrentHashMap<>();\n\n\t/**\n\t * Current virtual peers. Key is a turtle virtual peer.\n\t */\n\tprivate final Map<Location, TunnelDhInfo> dhPeers = new ConcurrentHashMap<>();\n\n\tprivate final ReentrantLock tunnelDataItemLock = new ReentrantLock();\n\tprivate final PriorityQueue<GxsTunnelDataItem> tunnelDataItems = new PriorityQueue<>();\n\n\tprivate GxsId ownGxsId;\n\tprivate TurtleRouter turtleRouter;\n\n\tpublic GxsTunnelRsService(RsServiceRegistry rsServiceRegistry, DatabaseSessionManager databaseSessionManager, IdentityService identityService)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.identityService = identityService;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\townGxsId = identityService.getOwnIdentity().getGxsId();\n\t\t}\n\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::manageTunnels,\n\t\t\t\tgetInitPriority().getMaxTime() + TUNNEL_DELAY_BETWEEN_RESEND.toSeconds(),\n\t\t\t\tTUNNEL_MANAGEMENT_DELAY.toSeconds());\n\t}\n\n\t@Override\n\tpublic void cleanup()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\tprivate void manageTunnels()\n\t{\n\t\tvar now = Instant.now();\n\n\t\tmanageResending(now);\n\t\tmanageDiggingAndCleanup(now);\n\t}\n\n\tprivate void manageResending(Instant now)\n\t{\n\t\ttunnelDataItemLock.lock();\n\t\ttry\n\t\t{\n\t\t\tvar item = tunnelDataItems.peek();\n\t\t\tif (item == null || Duration.between(item.getLastSendingAttempt(), now).compareTo(TUNNEL_DELAY_BETWEEN_RESEND) < 0)\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttunnelDataItems.poll(); // We need to remove it so that the priority is changed\n\t\t\tlog.debug(\"Resending tunnel data item for tunnel {}\", item.getLocation());\n\t\t\tsendEncryptedTunnelData(item.getLocation(), item);\n\t\t\titem.updateLastSendingAttempt();\n\t\t\ttunnelDataItems.offer(item); // Insert back with the proper priority\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\ttunnelDataItemLock.unlock();\n\t\t}\n\t\t// XXX: there should be a way to remove them?! I think it's only when the tunnel is removed, but I can't see where it's done in RS\n\t}\n\n\tprivate void manageDiggingAndCleanup(Instant now)\n\t{\n\t\tvar it = contacts.entrySet().iterator();\n\t\twhile (it.hasNext())\n\t\t{\n\t\t\tvar tunnelPeerInfoEntry = it.next();\n\n\t\t\t// Remove tunnels that were remotely closed as we\n\t\t\t// cannot use them anymore.\n\t\t\tif (tunnelPeerInfoEntry.getValue().getStatus() == REMOTELY_CLOSED && tunnelPeerInfoEntry.getValue().getLastContact().plusSeconds(20).isBefore(now))\n\t\t\t{\n\t\t\t\tlog.debug(\"Removing tunnel {}\", tunnelPeerInfoEntry.getKey());\n\t\t\t\tit.remove();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Re-digg tunnels that have died out of inaction\n\t\t\tif (tunnelPeerInfoEntry.getValue().getStatus() == CAN_TALK && tunnelPeerInfoEntry.getValue().getLastContact().plusSeconds(20).plus(TUNNEL_KEEP_ALIVE_TIMEOUT).isBefore(now))\n\t\t\t{\n\t\t\t\tlog.debug(\"Connection interrupted with tunnelPeerInfo\");\n\t\t\t\ttunnelPeerInfoEntry.getValue().setStatus(TUNNEL_DOWN);\n\t\t\t\tnotifyClients(tunnelPeerInfoEntry.getKey(), tunnelPeerInfoEntry.getValue().getStatus()); // XXX: OK?!\n\t\t\t\ttunnelPeerInfoEntry.getValue().clearLocation();\n\n\t\t\t\t// Reset the turtle router monitoring. Avoids having to wait 60 seconds for the tunnel to die.\n\t\t\t\tif (tunnelPeerInfoEntry.getValue().getDirection() == TunnelDirection.SERVER)\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Forcing new tunnel\");\n\t\t\t\t\tturtleRouter.forceReDiggTunnel(DestinationHash.createRandomHash(tunnelPeerInfoEntry.getValue().getDestinationGxsId()));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Send keep alive to active tunnels.\n\t\t\tif (tunnelPeerInfoEntry.getValue().getStatus() == CAN_TALK && tunnelPeerInfoEntry.getValue().getLastKeepAliveSent().plus(TUNNEL_KEEP_ALIVE_TIMEOUT).isBefore(now))\n\t\t\t{\n\t\t\t\tlog.debug(\"Sending keep alive to tunnel {}\", tunnelPeerInfoEntry.getKey());\n\t\t\t\tsendEncryptedTunnelData(tunnelPeerInfoEntry.getKey(), new GxsTunnelStatusItem(GxsTunnelStatusItem.Status.KEEP_ALIVE));\n\t\t\t\ttunnelPeerInfoEntry.getValue().updateLastKeepAlive();\n\t\t\t}\n\n\t\t\t// Clean old received messages\n\t\t\ttunnelPeerInfoEntry.getValue().cleanupReceivedMessagesOlderThan(TUNNEL_MESSAGES_DUPLICATE_DELAY);\n\t\t}\n\t}\n\n\tprivate void sendTunnelDataItem(Location destination, GxsTunnelDataItem item)\n\t{\n\t\ttunnelDataItemLock.lock();\n\t\ttry\n\t\t{\n\t\t\tlog.debug(\"Sending tunnel data item {} to tunnel {}\", item, destination);\n\t\t\tsendEncryptedTunnelData(destination, item);\n\t\t\titem.setForResending(destination);\n\t\t\ttunnelDataItems.offer(item);\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\ttunnelDataItemLock.unlock();\n\t\t}\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn GXS_TUNNELS;\n\t}\n\n\t@Override\n\tpublic void addRsSlave(GxsTunnelRsClient client)\n\t{\n\t\tvar serviceId = client.onGxsTunnelInitialization(this);\n\t\tclients.put(serviceId, client);\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\t// Nothing to handle\n\t}\n\n\t@Override\n\tpublic void initializeTurtle(TurtleRouter turtleRouter)\n\t{\n\t\tthis.turtleRouter = turtleRouter;\n\t}\n\n\t@Override\n\tpublic boolean handleTunnelRequest(PeerConnection sender, Sha1Sum hash)\n\t{\n\t\t// Only answer request that are for us.\n\t\tvar destination = DestinationHash.getGxsIdFromHash(hash);\n\t\tvar isForUs = ownGxsId.equals(destination);\n\t\tif (isForUs)\n\t\t{\n\t\t\tlog.trace(\"Tunnel request from {} is for us\", sender);\n\t\t}\n\t\treturn isForUs;\n\t}\n\n\t@Override\n\tpublic void receiveTurtleData(TurtleGenericTunnelItem item, Sha1Sum hash, Location virtualLocation, TunnelDirection tunnelDirection)\n\t{\n\t\tlog.debug(\"Received tunnel data item {} from {} (direction: {})\", item, virtualLocation, tunnelDirection);\n\t\tswitch (item)\n\t\t{\n\t\t\tcase TurtleGenericDataItem turtleGenericDataItem ->\n\t\t\t{\n\t\t\t\tvar buf = ByteBuffer.wrap(turtleGenericDataItem.getTunnelData());\n\n\t\t\t\t// The packet's first 8 bytes contain the IV\n\t\t\t\tif (buf.remaining() < 8)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Gxs tunnel data contains less than 8 bytes, dropping\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (hasNoIv(buf))\n\t\t\t\t{\n\t\t\t\t\t// Skip IV placeholder\n\t\t\t\t\tbuf.position(8);\n\t\t\t\t\tbuf.compact();\n\t\t\t\t\tbuf.position(0);\n\t\t\t\t\tvar deserializedItem = ItemUtils.deserializeItem(buf.array(), rsServiceRegistry);\n\t\t\t\t\tif (deserializedItem instanceof GxsTunnelDhPublicKeyItem gxsTunnelDhPublicKeyItem)\n\t\t\t\t\t{\n\t\t\t\t\t\thandleRecvDhPublicKeyItem(virtualLocation, gxsTunnelDhPublicKeyItem);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tlog.warn(\"Unknown deserialized item: {}\", deserializedItem);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t// Encrypted data\n\t\t\t\t\thandleEncryptedData(hash, virtualLocation, buf);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase null -> throw new IllegalStateException(\"Null item\");\n\t\t\tdefault -> log.warn(\"Unknown packet subtype received from turtle data: {}\", item.getSubType());\n\t\t}\n\t}\n\n\tprivate boolean hasNoIv(ByteBuffer buf)\n\t{\n\t\tbuf.mark();\n\t\tvar result = buf.getLong(0) == 0L;\n\t\tbuf.reset();\n\t\treturn result;\n\t}\n\n\tprivate void handleRecvDhPublicKeyItem(Location virtualLocation, GxsTunnelDhPublicKeyItem item)\n\t{\n\t\tlog.debug(\"Received DH public key from {}\", virtualLocation);\n\n\t\tvar tunnelDhInfo = dhPeers.get(virtualLocation);\n\t\tif (tunnelDhInfo == null)\n\t\t{\n\t\t\tlog.error(\"DH: Cannot find tunnelDhInfo for {}\", virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tPublicKey signerPublicKey;\n\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tsignerPublicKey = identityService.findByGxsId(item.getSignature().getGxsId())\n\t\t\t\t\t.map(GxsGroupItem::getAdminPublicKey)\n\t\t\t\t\t.orElse(getPublicKeySecurely(item.getSignerPublicKey()));\n\t\t}\n\n\t\tif (signerPublicKey == null)\n\t\t{\n\t\t\tlog.error(\"DH: Cannot find/process signer public key for {}\", tunnelDhInfo);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!item.getSignerPublicKey().getKeyGxsId().equals(item.getSignature().getGxsId()))\n\t\t{\n\t\t\tlog.error(\"DH: Signature does not match public key for {}\", tunnelDhInfo);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!RSA.verify(signerPublicKey, item.getSignature().getData(), BigIntegers.asUnsignedByteArray(item.getPublicKey())))\n\t\t{\n\t\t\tlog.error(\"DH: Signature verification failed for {}\", tunnelDhInfo);\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelDhInfo.getKeyPair() == null)\n\t\t{\n\t\t\tlog.error(\"DH: No information on tunnelDhInfo {}\", tunnelDhInfo);\n\t\t\treturn;\n\t\t}\n\t\tif (tunnelDhInfo.getStatus() == TunnelDhInfo.Status.KEY_AVAILABLE)\n\t\t{\n\t\t\tlog.debug(\"Key already available for {}, restarting DH session...\", tunnelDhInfo);\n\t\t\trestartDhSession(virtualLocation);\n\t\t}\n\n\t\tvar tunnelId = VirtualLocation.fromGxsIds(ownGxsId, item.getSignerPublicKey().getKeyGxsId());\n\t\ttunnelDhInfo.setTunnelId(tunnelId);\n\n\t\tvar publicKey = DiffieHellman.getPublicKey(item.getPublicKey());\n\t\tbyte[] commonSecret;\n\t\ttry\n\t\t{\n\t\t\tcommonSecret = DiffieHellman.generateCommonSecretKey(tunnelDhInfo.getKeyPair().getPrivate(), publicKey);\n\t\t}\n\t\tcatch (IllegalArgumentException _)\n\t\t{\n\t\t\tlog.error(\"DH: Cannot generate common secret key for {}\", tunnelDhInfo);\n\t\t\treturn;\n\t\t}\n\t\ttunnelDhInfo.setStatus(TunnelDhInfo.Status.KEY_AVAILABLE);\n\n\t\tvar tunnelPeerInfo = contacts.computeIfAbsent(tunnelId, _ -> new TunnelPeerInfo());\n\t\ttunnelPeerInfo.activate(generateAesKey(commonSecret), virtualLocation, tunnelDhInfo.getDirection(), item.getSignature().getGxsId());\n\n\t\tlog.debug(\"Sending distant connection ack for tunnel {}\", tunnelId);\n\t\tsendEncryptedTunnelData(tunnelId, new GxsTunnelStatusItem(GxsTunnelStatusItem.Status.ACK_DISTANT_CONNECTION));\n\t}\n\n\tprivate static PublicKey getPublicKeySecurely(SecurityKey securityKey)\n\t{\n\t\tif (!securityKey.getFlags().contains(TYPE_PUBLIC_ONLY))\n\t\t{\n\t\t\tlog.warn(\"Public key misses public flag\");\n\t\t\treturn null;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tRSA.getPrivateKey(securityKey.getData());\n\t\t\tlog.warn(\"Public key is in fact a private key, rejecting.\");\n\t\t\treturn null;\n\t\t}\n\t\tcatch (NoSuchAlgorithmException | InvalidKeySpecException _)\n\t\t{\n\t\t\t// All good\n\t\t}\n\n\t\tPublicKey publicKey;\n\n\t\ttry\n\t\t{\n\t\t\tpublicKey = RSA.getPublicKeyFromPkcs1(securityKey.getData());\n\t\t}\n\t\tcatch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e)\n\t\t{\n\t\t\tlog.warn(\"Couldn't decode public key: {}\", e.getMessage());\n\t\t\treturn null;\n\t\t}\n\n\t\tvar gxsId = RSA.getGxsId(publicKey);\n\n\t\tif (!securityKey.getKeyGxsId().equals(gxsId))\n\t\t{\n\t\t\t// RS used to generate those keys. They're still accepted, but they\n\t\t\t// will be removed one day.\n\t\t\tif (!securityKey.getKeyGxsId().equals(RSA.getGxsIdInsecure(publicKey)))\n\t\t\t{\n\t\t\t\tlog.warn(\"Old style key has wrong fingerprint, rejecting.\");\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tlog.warn(\"Using old style key. The peer should generate a new identity though.\");\n\t\t}\n\t\treturn publicKey;\n\t}\n\n\tprivate byte[] generateAesKey(byte[] commonSecret)\n\t{\n\t\tvar aesKey = new byte[16];\n\t\tvar digest = new Sha1MessageDigest();\n\t\tdigest.update(commonSecret);\n\t\tSystem.arraycopy(digest.getBytes(), 0, aesKey, 0, 16);\n\t\treturn aesKey;\n\t}\n\n\tprivate void handleEncryptedData(Sha1Sum hash, Location virtualLocation, ByteBuffer buf)\n\t{\n\t\tif (buf.remaining() < 8 + Sha1Sum.LENGTH)\n\t\t{\n\t\t\tlog.error(\"Encrypted data for hash {}, virtual location {} is too short\", hash, virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tvar tunnelDhInfo = dhPeers.get(virtualLocation);\n\t\tif (tunnelDhInfo == null)\n\t\t{\n\t\t\tlog.error(\"Incoming item not coming out of a registered tunnel for hash {}, virtual location {}. This is unexpected.\", hash, virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelDhInfo.getTunnelId() == null)\n\t\t{\n\t\t\tlog.error(\"No tunnel id for tunnelDhInfo for virtual location {}, this shouldn't happen\", virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tvar tunnelPeerInfo = contacts.get(tunnelDhInfo.getTunnelId());\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find tunnel tunnelDhInfo {}, virtual location {}\", tunnelDhInfo, virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tvar iv = new byte[8];\n\t\tbuf.get(iv);\n\t\tvar hmac = new byte[Sha1Sum.LENGTH];\n\t\tbuf.get(hmac);\n\t\tvar encryptedItem = new byte[buf.remaining()];\n\t\tbuf.get(encryptedItem);\n\n\t\tvar hmacCheck = new Sha1HMac(new SecretKeySpec(tunnelPeerInfo.getAesKey(), \"AES\"));\n\t\thmacCheck.update(encryptedItem);\n\n\t\tif (!Arrays.equals(hmac, hmacCheck.getBytes()))\n\t\t{\n\t\t\tlog.error(\"HMAC check failed for tunnelDhInfo {}, virtual location {}. Resetting DH session.\", tunnelDhInfo, virtualLocation);\n\t\t\trestartDhSession(virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tbyte[] decryptedItem;\n\n\t\ttry\n\t\t{\n\t\t\tdecryptedItem = AES.decrypt(tunnelPeerInfo.getAesKey(), iv, encryptedItem);\n\t\t}\n\t\tcatch (IllegalArgumentException e)\n\t\t{\n\t\t\tlog.error(\"Decryption failed for tunnelDhInfo {}, virtual location {}. : {}. Resetting DH session.\", tunnelDhInfo, virtualLocation, e.getMessage());\n\t\t\trestartDhSession(virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\ttunnelPeerInfo.setStatus(CAN_TALK);\n\t\ttunnelPeerInfo.updateLastContact();\n\n\t\tvar item = ItemUtils.deserializeItem(decryptedItem, rsServiceRegistry);\n\n\t\tif (item.getServiceType() == RsServiceType.NONE.getType())\n\t\t{\n\t\t\tlog.error(\"Deserialization failed for tunnelDhInfo {}, virtual location {}\", tunnelDhInfo, virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\ttunnelPeerInfo.addReceivedSize(decryptedItem.length);\n\n\t\thandleIncomingItem(tunnelDhInfo.getTunnelId(), item);\n\t}\n\n\tprivate void handleIncomingItem(Location tunnelId, Item item)\n\t{\n\t\tswitch (item)\n\t\t{\n\t\t\tcase GxsTunnelDataItem gxsTunnelDataItem -> handleTunnelDataItem(tunnelId, gxsTunnelDataItem);\n\t\t\tcase GxsTunnelDataAckItem gxsTunnelDataAckItem -> handleTunnelDataItemAck(gxsTunnelDataAckItem);\n\t\t\tcase GxsTunnelStatusItem gxsTunnelStatusItem -> handleTunnelStatusItem(tunnelId, gxsTunnelStatusItem);\n\t\t\tdefault -> log.warn(\"Unknown packet subtype received from encrypted data: {}\", item.getSubType());\n\t\t}\n\t}\n\n\tprivate void handleTunnelDataItem(Location tunnelId, GxsTunnelDataItem item)\n\t{\n\t\t// Acknowledge reception\n\t\tvar ackItem = new GxsTunnelDataAckItem(item.getCounter());\n\t\tlog.debug(\"Sending ack for tunnel {}\", tunnelId);\n\t\tsendEncryptedTunnelData(tunnelId, ackItem);\n\n\t\tvar client = clients.get(item.getServiceId());\n\t\tif (client == null)\n\t\t{\n\t\t\tlog.warn(\"No registered service with ID {}, rejecting item\", item.getServiceId());\n\t\t\treturn;\n\t\t}\n\n\t\tvar isClientSide = false;\n\n\t\tvar tunnelPeerInfo = contacts.get(tunnelId);\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\tlog.error(\"No contact found for {}\", item.getServiceId());\n\t\t\treturn;\n\t\t}\n\n\t\ttunnelPeerInfo.addService(item.getServiceId());\n\t\tisClientSide = tunnelPeerInfo.getDirection() == TunnelDirection.SERVER;\n\n\t\t// We check if we already received.\n\t\tif (tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(item.getCounter()))\n\t\t{\n\t\t\tlog.warn(\"Tunnel peer already received a message for {}\", item.getServiceId());\n\t\t\treturn;\n\t\t}\n\n\t\tif (client.onGxsTunnelDataAuthorization(tunnelPeerInfo.getDestinationGxsId(), tunnelId, isClientSide))\n\t\t{\n\t\t\tclient.onGxsTunnelDataReceived(tunnelId, item.getTunnelData());\n\t\t}\n\t}\n\n\tprivate void handleTunnelDataItemAck(GxsTunnelDataAckItem item)\n\t{\n\t\ttunnelDataItemLock.lock();\n\t\ttry\n\t\t{\n\t\t\ttunnelDataItems.removeIf(gxsTunnelDataItem -> gxsTunnelDataItem.getCounter() == item.getCounter());\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\ttunnelDataItemLock.unlock();\n\t\t}\n\t}\n\n\tprivate void handleTunnelStatusItem(Location tunnelId, GxsTunnelStatusItem item)\n\t{\n\t\tswitch (item.getStatus())\n\t\t{\n\t\t\tcase CLOSING_DISTANT_CONNECTION ->\n\t\t\t{\n\t\t\t\tvar tunnelPeerInfo = contacts.get(tunnelId);\n\t\t\t\tif (tunnelPeerInfo == null)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Cannot mark tunnel connection as closed. No connected opened for tunnel id {}\", tunnelId);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (tunnelPeerInfo.getDirection() == TunnelDirection.CLIENT)\n\t\t\t\t{\n\t\t\t\t\ttunnelPeerInfo.setStatus(REMOTELY_CLOSED);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\ttunnelPeerInfo.setStatus(TUNNEL_DOWN);\n\t\t\t\t}\n\t\t\t\tlog.debug(\"Remote tunnel for tunnel id {} closed\", tunnelId);\n\t\t\t\tnotifyClients(tunnelId, REMOTELY_CLOSED);\n\t\t\t}\n\t\t\tcase KEEP_ALIVE -> log.debug(\"Received keep alive for tunnel {}\", tunnelId); // Nothing to do, decryption method updated the activity for the tunnel\n\t\t\tcase ACK_DISTANT_CONNECTION -> notifyClients(tunnelId, CAN_TALK);\n\t\t\tdefault -> log.warn(\"Unknown status received: {}\", item.getStatus());\n\t\t}\n\t}\n\n\tprivate void notifyClients(Location tunnelId, GxsTunnelStatus status)\n\t{\n\t\tvar tunnelPeerInfo = contacts.get(tunnelId);\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\ttunnelPeerInfo.getClientServices().forEach(serviceId -> {\n\t\t\tvar client = clients.get(serviceId);\n\t\t\tif (client != null)\n\t\t\t{\n\t\t\t\tclient.onGxsTunnelStatusChanged(tunnelId, tunnelPeerInfo.getDestinationGxsId(), status);\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic List<byte[]> receiveSearchRequest(byte[] query, int maxHits)\n\t{\n\t\treturn List.of();\n\t}\n\n\t@Override\n\tpublic void receiveSearchRequestString(PeerConnection sender, String keywords)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void receiveSearchResult(int requestId, TurtleSearchResultItem item)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void addVirtualPeer(Sha1Sum hash, Location virtualLocation, TunnelDirection direction)\n\t{\n\t\tlog.debug(\"Received new virtual peer {} for hash {}, direction {}\", virtualLocation, hash, direction);\n\n\t\tvar tunnelDhInfo = dhPeers.computeIfAbsent(virtualLocation, _ -> new TunnelDhInfo());\n\t\ttunnelDhInfo.clear();\n\t\ttunnelDhInfo.setDirection(direction);\n\t\ttunnelDhInfo.setHash(hash);\n\t\ttunnelDhInfo.setStatus(UNINITIALIZED);\n\n\t\tif (direction == TunnelDirection.SERVER)\n\t\t{\n\t\t\tvar found = contacts.values().stream()\n\t\t\t\t\t.filter(tunnelPeerInfo -> tunnelPeerInfo.getHash().equals(hash))\n\t\t\t\t\t.findAny();\n\n\t\t\tif (found.isEmpty())\n\t\t\t{\n\t\t\t\tlog.error(\"No pre-registered peer for hash {} on client side\", hash);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (found.get().getStatus() == CAN_TALK)\n\t\t\t{\n\t\t\t\tlog.error(\"Session already opened and alive\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tlog.debug(\"Adding virtual peer {} for hash {}\", virtualLocation, hash);\n\n\t\trestartDhSession(virtualLocation);\n\t}\n\n\t@Override\n\tpublic void removeVirtualPeer(Sha1Sum hash, Location virtualLocation)\n\t{\n\t\tvar tunnelDhInfo = dhPeers.remove(virtualLocation);\n\n\t\tif (tunnelDhInfo == null)\n\t\t{\n\t\t\tlog.error(\"Cannot remove virtual peer {} because it's not found\", virtualLocation);\n\t\t\treturn;\n\t\t}\n\n\t\tvar tunnelPeerInfo = contacts.get(tunnelDhInfo.getTunnelId());\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find tunnel id {} in contact list\", tunnelDhInfo.getTunnelId());\n\t\t\treturn;\n\t\t}\n\n\t\t// Notify all clients that this tunnel is down.\n\t\tif (tunnelDhInfo.getTunnelId().equals(virtualLocation))\n\t\t{\n\t\t\ttunnelPeerInfo.setStatus(TUNNEL_DOWN);\n\t\t\ttunnelPeerInfo.clearLocation();\n\n\t\t\ttunnelPeerInfo.getClientServices().forEach(serviceId -> {\n\t\t\t\tvar client = clients.get(serviceId);\n\t\t\t\tif (client != null)\n\t\t\t\t{\n\t\t\t\t\tclient.onGxsTunnelStatusChanged(tunnelDhInfo.getTunnelId(), tunnelPeerInfo.getDestinationGxsId(), TUNNEL_DOWN);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\tprivate void restartDhSession(Location virtualLocation)\n\t{\n\t\tvar tunnelDhInfo = dhPeers.computeIfAbsent(virtualLocation, _ -> new TunnelDhInfo());\n\t\ttunnelDhInfo.setStatus(UNINITIALIZED);\n\t\ttunnelDhInfo.setKeyPair(DiffieHellman.generateKeys());\n\t\ttunnelDhInfo.setStatus(HALF_KEY_DONE);\n\n\t\tsendDhPublicKey(virtualLocation, tunnelDhInfo.getKeyPair());\n\t}\n\n\tprivate void sendDhPublicKey(Location virtualLocation, KeyPair keyPair)\n\t{\n\t\tassert keyPair != null;\n\n\t\tlog.debug(\"Sending DH public key to {}\", virtualLocation);\n\n\t\t// Sign the public key\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\t\tvar signerSecurityKey = new SecurityKey(ownIdentity.getGxsId(), EnumSet.of(DISTRIBUTION_ADMIN, TYPE_PUBLIC_ONLY), ownIdentity.getPublished(), null, RSA.getPublicKeyAsPkcs1(ownIdentity.getAdminPublicKey()));\n\n\t\t\tvar publicKeyNum = ((DHPublicKey) keyPair.getPublic()).getY();\n\n\t\t\tvar signature = new Signature(ownIdentity.getGxsId(), RSA.sign(ownIdentity.getAdminPrivateKey(), BigIntegers.asUnsignedByteArray(publicKeyNum)));\n\n\t\t\tvar item = new GxsTunnelDhPublicKeyItem(publicKeyNum, signature, signerSecurityKey);\n\t\t\tvar serializedItem = ItemUtils.serializeItem(item, this);\n\n\t\t\t// The preceding IV is made of zeroes as this is the only clear item that is sent.\n\t\t\tvar data = new byte[serializedItem.length + 8];\n\t\t\tSystem.arraycopy(serializedItem, 0, data, 8, serializedItem.length);\n\n\t\t\tturtleRouter.sendTurtleData(virtualLocation, new TurtleGenericFastDataItem(data));\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Cannot read public key from database: \" + e.getMessage(), e);\n\t\t}\n\t}\n\n\tprivate void sendEncryptedTunnelData(Location destination, GxsTunnelItem item)\n\t{\n\t\tvar serializedItem = ItemUtils.serializeItem(item, this);\n\n\t\tvar tunnelPeerInfo = contacts.get(destination);\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find tunnelPeerInfo for {} when trying to send encrypted data\", destination);\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelPeerInfo.getStatus() != CAN_TALK)\n\t\t{\n\t\t\tlog.error(\"Cannot talk to tunnel id {}, status is {}\", destination, tunnelPeerInfo.getStatus());\n\t\t\treturn;\n\t\t}\n\n\t\ttunnelPeerInfo.addSentSize(serializedItem.length);\n\n\t\tvar iv = new byte[8];\n\t\tSecureRandomUtils.nextBytes(iv);\n\n\t\tvar key = tunnelPeerInfo.getAesKey();\n\n\t\tvar encryptedItem = AES.encrypt(key, iv, serializedItem);\n\t\tvar turtleItem = new TurtleGenericFastDataItem(createTurtleData(key, iv, encryptedItem));\n\t\tturtleRouter.sendTurtleData(tunnelPeerInfo.getLocation(), turtleItem);\n\t}\n\n\t/**\n\t * Asks for a tunnel. The service will request it to the turtle router, and exchange an AES key using DH.\n\t * When the tunnel is established, a {@link GxsTunnelRsClient#onGxsTunnelStatusChanged(Location, GxsId, GxsTunnelStatus)}  method will be received.\n\t * Data can then be sent and received in the tunnel. A same tunnel can be used by several clients, hence they're differentiated\n\t * by the serviceId parameter.\n\t *\n\t * @param from the originating identity\n\t * @param to the destination identity\n\t * @param serviceId the service id\n\t * @return a tunnel id or null if it already exists\n\t */\n\tpublic Location requestSecuredTunnel(GxsId from, GxsId to, int serviceId)\n\t{\n\t\tvar hash = DestinationHash.createRandomHash(to);\n\t\tvar tunnelId = VirtualLocation.fromGxsIds(from, to);\n\n\t\tlog.debug(\"Requesting secured tunnel for gxs id {}, resulting tunnel id: {}\", to, tunnelId);\n\n\t\tif (contacts.putIfAbsent(tunnelId, new TunnelPeerInfo(hash, to, serviceId)) != null)\n\t\t{\n\t\t\tlog.error(\"Tunnel {} already exists\", tunnelId);\n\t\t\treturn null;\n\t\t}\n\n\t\tturtleRouter.startMonitoringTunnels(hash, this, false);\n\n\t\treturn tunnelId;\n\t}\n\n\t/**\n\t * Gets the destination GxS identity from a tunnel.\n\t *\n\t * @param tunnelId the tunnel id\n\t * @return the identity\n\t */\n\tpublic GxsId getGxsFromTunnel(Location tunnelId)\n\t{\n\t\tvar tunnelPeerInfo = contacts.get(tunnelId);\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn tunnelPeerInfo.getDestinationGxsId();\n\t}\n\n\t/**\n\t * Sends data through the tunnel. If a tunnel is present, retries are performed automatically until the reception is acknowledged by the other end.\n\t *\n\t * @param tunnelId  the tunnel id\n\t * @param serviceId the service id\n\t * @param data      the data\n\t * @return true if successful, false if the tunnel doesn't exist\n\t */\n\tpublic boolean sendData(Location tunnelId, int serviceId, byte[] data)\n\t{\n\t\tvar tunnelPeerInfo = contacts.get(tunnelId);\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\tlog.error(\"No tunnel peer info found for {}\", tunnelId);\n\t\t\treturn false;\n\t\t}\n\n\t\tvar client = clients.get(serviceId);\n\t\tif (client == null)\n\t\t{\n\t\t\tlog.error(\"Cannot find client for {}\", serviceId);\n\t\t\treturn false;\n\t\t}\n\t\tsendTunnelDataItem(tunnelId, new GxsTunnelDataItem(getUniquePacketCounter(), serviceId, data));\n\t\treturn true;\n\t}\n\n\t/**\n\t * Closes and established tunnel. All further data will be refused but the tunnel will be kept alive for a little\n\t * while until all pending data is delivered. Clients will receive a {@link GxsTunnelRsClient#onGxsTunnelStatusChanged(Location, GxsId, GxsTunnelStatus)} method\n\t * once the tunnel gets closed.\n\t *\n\t * @param tunnelId the tunnel id\n\t * @param serviceId the service id\n\t */\n\tpublic void closeExistingTunnel(Location tunnelId, int serviceId)\n\t{\n\t\tvar tunnelPeerInfo = contacts.get(tunnelId);\n\t\tif (tunnelPeerInfo == null)\n\t\t{\n\t\t\tlog.error(\"Cannot close distant tunnel connection. No connection opened for tunnel id {}\", tunnelId);\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelPeerInfo.getLocation() == null)\n\t\t{\n\t\t\tlog.warn(\"Connection already closed for tunnel id {}\", tunnelId);\n\t\t\treturn;\n\t\t}\n\n\t\tSha1Sum hash;\n\n\t\tvar tunnelDhInfo = dhPeers.get(tunnelPeerInfo.getLocation());\n\t\tif (tunnelDhInfo != null)\n\t\t{\n\t\t\thash = tunnelDhInfo.getHash();\n\t\t}\n\t\telse\n\t\t{\n\t\t\thash = tunnelPeerInfo.getHash();\n\t\t}\n\n\t\tif (!tunnelPeerInfo.getClientServices().contains(serviceId))\n\t\t{\n\t\t\tlog.error(\"Tunnel {} is not associated with service {}\", tunnelId, serviceId);\n\t\t\treturn;\n\t\t}\n\n\t\ttunnelPeerInfo.removeService(serviceId);\n\n\t\tif (tunnelPeerInfo.getClientServices().isEmpty())\n\t\t{\n\t\t\t// No clients, we can close the tunnel.\n\t\t\tlog.debug(\"Sending close tunnel status to tunnel id {}\", tunnelId);\n\t\t\tsendEncryptedTunnelData(tunnelId, new GxsTunnelStatusItem(GxsTunnelStatusItem.Status.CLOSING_DISTANT_CONNECTION));\n\n\t\t\tif (tunnelPeerInfo.getDirection() == TunnelDirection.SERVER)\n\t\t\t{\n\t\t\t\tturtleRouter.stopMonitoringTunnels(hash);\n\t\t\t}\n\n\t\t\tcontacts.remove(tunnelId);\n\t\t}\n\t}\n\n\tprivate long getUniquePacketCounter()\n\t{\n\t\treturn counter.getAndIncrement();\n\t}\n\n\tprivate byte[] createTurtleData(byte[] aesKey, byte[] iv, byte[] encryptedItem)\n\t{\n\t\tvar turtleData = new byte[iv.length + Sha1Sum.LENGTH + encryptedItem.length];\n\n\t\tSystem.arraycopy(iv, 0, turtleData, 0, iv.length);\n\n\t\tvar hmac = new Sha1HMac(new SecretKeySpec(aesKey, \"AES\"));\n\t\thmac.update(encryptedItem);\n\n\t\tSystem.arraycopy(hmac.getBytes(), 0, turtleData, iv.length, Sha1Sum.LENGTH);\n\t\tSystem.arraycopy(encryptedItem, 0, turtleData, iv.length + Sha1Sum.LENGTH, encryptedItem.length);\n\t\treturn turtleData;\n\t}\n\n\t@Override\n\tpublic RsServiceType getMasterServiceType()\n\t{\n\t\treturn TURTLE_ROUTER;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/GxsTunnelStatus.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\npublic enum GxsTunnelStatus\n{\n\tUNKNOWN,\n\tTUNNEL_DOWN,\n\tCAN_TALK,\n\tREMOTELY_CLOSED\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/TunnelDhInfo.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.turtle.item.TunnelDirection;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.security.KeyPair;\n\n/**\n * Used to keep track of a Diffie-Hellman session.\n */\nclass TunnelDhInfo\n{\n\tpublic enum Status\n\t{\n\t\tUNINITIALIZED,\n\t\tHALF_KEY_DONE,\n\t\tKEY_AVAILABLE\n\t}\n\n\tprivate Status status;\n\tprivate Sha1Sum hash;\n\tprivate TunnelDirection direction;\n\tprivate KeyPair keyPair;\n\tprivate Location tunnelId;\n\n\tpublic Status getStatus()\n\t{\n\t\treturn status;\n\t}\n\n\tpublic void setStatus(Status status)\n\t{\n\t\tthis.status = status;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic void setHash(Sha1Sum hash)\n\t{\n\t\tthis.hash = hash;\n\t}\n\n\tpublic TunnelDirection getDirection()\n\t{\n\t\treturn direction;\n\t}\n\n\tpublic void setDirection(TunnelDirection direction)\n\t{\n\t\tthis.direction = direction;\n\t}\n\n\tpublic KeyPair getKeyPair()\n\t{\n\t\treturn keyPair;\n\t}\n\n\tpublic void setKeyPair(KeyPair keyPair)\n\t{\n\t\tthis.keyPair = keyPair;\n\t}\n\n\tpublic Location getTunnelId()\n\t{\n\t\treturn tunnelId;\n\t}\n\n\tpublic void setTunnelId(Location tunnelId)\n\t{\n\t\tthis.tunnelId = tunnelId;\n\t}\n\n\tpublic void clear()\n\t{\n\t\tstatus = Status.UNINITIALIZED;\n\t\tkeyPair = null;\n\t\ttunnelId = null;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TunnelDhInfo{\" +\n\t\t\t\t\"status=\" + status +\n\t\t\t\t\", direction=\" + direction +\n\t\t\t\t\", tunnelId=\" + tunnelId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/TunnelPeerInfo.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.turtle.item.TunnelDirection;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.HashSet;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * A tunnel established with a peer.\n */\nclass TunnelPeerInfo\n{\n\tprivate Instant lastContact;\n\tprivate Instant lastKeepAliveSent;\n\tprivate byte[] aesKey;\n\tprivate Sha1Sum hash;\n\n\t/**\n\t * Tells if the tunnel is open or not.\n\t */\n\tprivate GxsTunnelStatus status;\n\n\t/**\n\t * The virtual turtle peer.\n\t */\n\tprivate Location location;\n\n\t/**\n\t * Identity we're talking to.\n\t */\n\tprivate GxsId destinationGxsId;\n\n\t/**\n\t * If we are a client (managing the tunnel) or a server.\n\t */\n\tprivate TunnelDirection direction;\n\n\t/**\n\t * Services using this tunnel.\n\t */\n\tprivate final Set<Integer> clientServices = new HashSet<>();\n\n\t/**\n\t * Keeps last received messages, to avoid duplicates.\n\t */\n\tprivate final Map<Long, Instant> receivedMessages = new ConcurrentHashMap<>();\n\n\tprivate long totalSent;\n\tprivate long totalReceived;\n\n\tpublic TunnelPeerInfo(Sha1Sum hash, GxsId destinationGxsId, int serviceId)\n\t{\n\t\tvar now = Instant.now();\n\n\t\tlastContact = now;\n\t\tlastKeepAliveSent = now;\n\t\tstatus = GxsTunnelStatus.TUNNEL_DOWN;\n\t\tdirection = TunnelDirection.SERVER;\n\t\tthis.hash = hash;\n\t\tthis.destinationGxsId = destinationGxsId;\n\t\tclientServices.add(serviceId);\n\t}\n\n\tpublic TunnelPeerInfo()\n\t{\n\n\t}\n\n\tpublic void activate(byte[] aesKey, Location location, TunnelDirection direction, GxsId destination)\n\t{\n\t\tvar now = Instant.now();\n\n\t\tlastContact = now;\n\t\tlastKeepAliveSent = now;\n\t\tstatus = GxsTunnelStatus.CAN_TALK;\n\t\tthis.aesKey = aesKey;\n\t\tthis.location = location;\n\t\tthis.direction = direction;\n\t\tthis.destinationGxsId = destination;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic GxsTunnelStatus getStatus()\n\t{\n\t\treturn status;\n\t}\n\n\tpublic void setStatus(GxsTunnelStatus status)\n\t{\n\t\tthis.status = status;\n\t}\n\n\tpublic void clearLocation()\n\t{\n\t\tlocation = null;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic byte[] getAesKey()\n\t{\n\t\treturn aesKey;\n\t}\n\n\tpublic TunnelDirection getDirection()\n\t{\n\t\treturn direction;\n\t}\n\n\tpublic GxsId getDestinationGxsId()\n\t{\n\t\treturn destinationGxsId;\n\t}\n\n\tpublic Set<Integer> getClientServices()\n\t{\n\t\treturn clientServices;\n\t}\n\n\tpublic Instant getLastContact()\n\t{\n\t\treturn lastContact;\n\t}\n\n\tpublic Instant getLastKeepAliveSent()\n\t{\n\t\treturn lastKeepAliveSent;\n\t}\n\n\tpublic void updateLastKeepAlive()\n\t{\n\t\tlastKeepAliveSent = Instant.now();\n\t}\n\n\tpublic void addSentSize(int size)\n\t{\n\t\ttotalSent += size;\n\t}\n\n\tpublic void addReceivedSize(int size)\n\t{\n\t\ttotalReceived += size;\n\t}\n\n\tpublic void updateLastContact()\n\t{\n\t\tlastContact = Instant.now();\n\t}\n\n\tpublic void addService(int serviceId)\n\t{\n\t\tclientServices.add(serviceId);\n\t}\n\n\tpublic void removeService(int serviceId)\n\t{\n\t\tclientServices.remove(serviceId);\n\t}\n\n\tpublic boolean checkIfMessageAlreadyReceivedAndRecord(long messageId)\n\t{\n\t\treturn receivedMessages.putIfAbsent(messageId, Instant.now()) != null;\n\t}\n\n\tpublic void cleanupReceivedMessagesOlderThan(Duration delay)\n\t{\n\t\tvar now = Instant.now();\n\n\t\treceivedMessages.entrySet().removeIf(entry -> entry.getValue().plus(delay).isAfter(now));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TunnelPeerInfo{\" +\n\t\t\t\t\"status=\" + status +\n\t\t\t\t\", location=\" + location +\n\t\t\t\t\", destination=\" + destinationGxsId +\n\t\t\t\t\", direction=\" + direction +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/VirtualLocation.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport java.nio.ByteBuffer;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\n\nfinal class VirtualLocation\n{\n\tprivate VirtualLocation()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Location fromGxsIds(GxsId ownId, GxsId distantId)\n\t{\n\t\tvar buf = new byte[GxsId.LENGTH * 2];\n\n\t\t// Sort the IDs, that way the same ID is generated on both sides.\n\t\t// This helps with debugging.\n\t\tif (ownId.compareTo(distantId) < 0)\n\t\t{\n\t\t\tSystem.arraycopy(ownId.getBytes(), 0, buf, 0, GxsId.LENGTH);\n\t\t\tSystem.arraycopy(distantId.getBytes(), 0, buf, GxsId.LENGTH, distantId.getLength());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tSystem.arraycopy(distantId.getBytes(), 0, buf, 0, GxsId.LENGTH);\n\t\t\tSystem.arraycopy(ownId.getBytes(), 0, buf, GxsId.LENGTH, ownId.getLength());\n\t\t}\n\t\tvar digest = new Sha1MessageDigest();\n\t\tdigest.update(buf);\n\t\tvar wrap = ByteBuffer.wrap(digest.getBytes());\n\n\t\t// Only get the first 16 bytes\n\t\tvar out = new byte[LocationIdentifier.LENGTH];\n\t\twrap.get(out);\n\n\t\treturn Location.createLocation(\"GxsTunnelVirtualLocation\", new LocationIdentifier(out));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelDataAckItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\npublic class GxsTunnelDataAckItem extends GxsTunnelItem\n{\n\t@RsSerialized\n\tprivate long counter;\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 4;\n\t}\n\n\tpublic GxsTunnelDataAckItem()\n\t{\n\t\t// Needed\n\t}\n\n\tpublic GxsTunnelDataAckItem(long counter)\n\t{\n\t\tthis.counter = counter;\n\t}\n\n\tpublic long getCounter()\n\t{\n\t\treturn counter;\n\t}\n\n\t@Override\n\tpublic GxsTunnelDataAckItem clone()\n\t{\n\t\treturn (GxsTunnelDataAckItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTunnelDataAckItem{\" +\n\t\t\t\t\"counter=\" + counter +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelDataItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel.item;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport java.time.Instant;\n\npublic class GxsTunnelDataItem extends GxsTunnelItem implements Comparable<GxsTunnelDataItem>\n{\n\t@RsSerialized\n\tprivate long counter;\n\n\t@RsSerialized\n\tprivate int flags; // Not used\n\n\t@RsSerialized\n\tprivate int serviceId;\n\n\t@RsSerialized\n\tprivate byte[] tunnelData;\n\n\t// Used for resending\n\tprivate Instant lastSendingAttempt = Instant.EPOCH;\n\tprivate Location location;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsTunnelDataItem()\n\t{\n\t}\n\n\tpublic GxsTunnelDataItem(long counter, int serviceId, byte[] tunnelData)\n\t{\n\t\tthis.counter = counter;\n\t\tthis.serviceId = serviceId;\n\t\tthis.tunnelData = tunnelData;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\tpublic long getCounter()\n\t{\n\t\treturn counter;\n\t}\n\n\tpublic int getServiceId()\n\t{\n\t\treturn serviceId;\n\t}\n\n\tpublic byte[] getTunnelData()\n\t{\n\t\treturn tunnelData;\n\t}\n\n\tpublic void updateLastSendingAttempt()\n\t{\n\t\tlastSendingAttempt = Instant.now();\n\t}\n\n\tpublic Instant getLastSendingAttempt()\n\t{\n\t\treturn lastSendingAttempt;\n\t}\n\n\tpublic void setForResending(Location location)\n\t{\n\t\tthis.location = location;\n\t\tupdateLastSendingAttempt();\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\t@Override\n\tpublic int compareTo(GxsTunnelDataItem o)\n\t{\n\t\treturn lastSendingAttempt.compareTo(o.lastSendingAttempt);\n\t}\n\n\t@Override\n\tpublic GxsTunnelDataItem clone()\n\t{\n\t\treturn (GxsTunnelDataItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTunnelDataItem{\" +\n\t\t\t\t\"serviceId=\" + serviceId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelDhPublicKeyItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel.item;\n\nimport io.xeres.app.xrs.common.SecurityKey;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.serialization.TlvType;\n\nimport java.math.BigInteger;\n\npublic class GxsTunnelDhPublicKeyItem extends GxsTunnelItem\n{\n\t@RsSerialized\n\tprivate BigInteger publicKey;\n\n\t@RsSerialized(tlvType = TlvType.SIGNATURE)\n\tprivate Signature signature;\n\n\t@RsSerialized(tlvType = TlvType.SECURITY_KEY)\n\tprivate SecurityKey signerPublicKey;\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsTunnelDhPublicKeyItem()\n\t{\n\t}\n\n\tpublic GxsTunnelDhPublicKeyItem(BigInteger publicKey, Signature signature, SecurityKey signerPublicKey)\n\t{\n\t\tthis.publicKey = publicKey;\n\t\tthis.signature = signature;\n\t\tthis.signerPublicKey = signerPublicKey;\n\t}\n\n\tpublic BigInteger getPublicKey()\n\t{\n\t\treturn publicKey;\n\t}\n\n\tpublic Signature getSignature()\n\t{\n\t\treturn signature;\n\t}\n\n\tpublic SecurityKey getSignerPublicKey()\n\t{\n\t\treturn signerPublicKey;\n\t}\n\n\t@Override\n\tpublic GxsTunnelDhPublicKeyItem clone()\n\t{\n\t\treturn (GxsTunnelDhPublicKeyItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTunnelDhPublicKeyItem{\" +\n\t\t\t\t\"publicKey=\" + publicKey +\n\t\t\t\t\", signature=\" + signature +\n\t\t\t\t\", signerPublicKey=\" + signerPublicKey +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic abstract class GxsTunnelItem extends Item\n{\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.GXS_TUNNELS.getType();\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority(); // Same as Chat service\n\t}\n\n\t@Override\n\tpublic GxsTunnelItem clone()\n\t{\n\t\treturn (GxsTunnelItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTunnelItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/gxstunnel/item/GxsTunnelStatusItem.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport java.util.EnumSet;\nimport java.util.Set;\n\npublic class GxsTunnelStatusItem extends GxsTunnelItem\n{\n\tpublic enum Status\n\t{\n\t\tUNUSED_1,\n\t\tUNUSED_2,\n\t\tUNUSED_3,\n\t\tUNUSED_4,\n\t\tUNUSED_5,\n\t\tUNUSED_6,\n\t\tUNUSED_7,\n\t\tUNUSED_8,\n\t\tUNUSED_9,\n\t\tUNUSED_10,\n\t\tCLOSING_DISTANT_CONNECTION,\n\t\tACK_DISTANT_CONNECTION,\n\t\tKEEP_ALIVE\n\t}\n\n\t@RsSerialized\n\tprivate Set<Status> status;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic GxsTunnelStatusItem()\n\t{\n\t}\n\n\tpublic GxsTunnelStatusItem(Status status)\n\t{\n\t\tthis.status = EnumSet.of(status);\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\tpublic Status getStatus()\n\t{\n\t\t// XXX: we should add some warning when a status we don't know is wedged in there\n\t\treturn status.stream().findFirst().orElse(Status.UNUSED_1);\n\t}\n\n\t@Override\n\tpublic GxsTunnelStatusItem clone()\n\t{\n\t\treturn (GxsTunnelStatusItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"GxsTunnelStatusItem{\" +\n\t\t\t\t\"status=\" + status +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/heartbeat/HeartbeatRsService.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.heartbeat;\n\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.heartbeat.item.HeartbeatItem;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.common.protocol.xrs.RsServiceType.HEARTBEAT;\n\n@Component\npublic class HeartbeatRsService extends RsService\n{\n\tprivate final PeerConnectionManager peerConnectionManager;\n\n\tpublic HeartbeatRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn HEARTBEAT;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.NORMAL;\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.scheduleAtFixedRate(() -> peerConnectionManager.writeItem(peerConnection, new HeartbeatItem(), this),\n\t\t\t\t5,\n\t\t\t\t5,\n\t\t\t\tTimeUnit.SECONDS);\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\t// do nothing\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/heartbeat/item/HeartbeatItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.heartbeat.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class HeartbeatItem extends Item\n{\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.HEARTBEAT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.IMPORTANT.getPriority();\n\t}\n\n\t@Override\n\tpublic HeartbeatItem clone()\n\t{\n\t\treturn (HeartbeatItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"HeartbeatItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/identity/IdentityManager.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.identity.Type;\nimport io.xeres.common.util.ExecutorUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.stereotype.Component;\n\nimport java.time.Duration;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.stream.Collectors;\n\n/**\n * Manages GxsId requests, caching and storage in an intelligent way, like:\n * <ul>\n *     <li>group requests to ask in batches</li>\n *     <li>remembers which peer is likely to answer requests (basic routing)</li>\n *     <li>caches recent GxsIds</li>\n * </ul>\n */\n@Component\npublic class IdentityManager\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(IdentityManager.class);\n\tprivate final Map<Long, Set<GxsId>> pendingGxsIds = new HashMap<>();\n\tprivate final Set<GxsId> friendGxsIds = new HashSet<>();\n\n\tprivate static final Duration TIME_BETWEEN_REQUESTS = Duration.ofSeconds(5);\n\n\tprivate static final int MAXIMUM_IDS_PER_LOCATION = 5;\n\n\tprivate final IdentityRsService identityRsService;\n\tprivate final IdentityService identityService;\n\tprivate final PeerConnectionManager peerConnectionManager;\n\n\tprivate final ScheduledExecutorService executorService;\n\n\t// XXX: try to fix the circular dependency injection\n\tpublic IdentityManager(@Lazy IdentityRsService identityRsService, IdentityService identityService, PeerConnectionManager peerConnectionManager)\n\t{\n\t\tthis.identityRsService = identityRsService;\n\t\tthis.identityService = identityService;\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::requestGxsIds,\n\t\t\t\tTIME_BETWEEN_REQUESTS.toSeconds());\n\t}\n\n\tpublic void shutdown()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\t/**\n\t * Gets a gxs group, if available. Otherwise, put a request to fetch it later\n\t *\n\t * @param peerConnection the peer to try to get the gxs group from\n\t * @param gxsId          the gxs group id\n\t * @return the gxs group, or null if not found yet\n\t */\n\tpublic IdentityGroupItem getGxsGroup(PeerConnection peerConnection, GxsId gxsId)\n\t{\n\t\tsynchronized (pendingGxsIds)\n\t\t{\n\t\t\treturn identityService.findByGxsId(gxsId).orElseGet(() -> {\n\t\t\t\tvar gxsIds = pendingGxsIds.getOrDefault(peerConnection.getLocation().getId(), ConcurrentHashMap.newKeySet());\n\t\t\t\tgxsIds.add(gxsId);\n\t\t\t\tpendingGxsIds.put(peerConnection.getLocation().getId(), gxsIds);\n\t\t\t\treturn null;\n\t\t\t});\n\t\t}\n\t}\n\n\tpublic IdentityGroupItem getGxsGroup(GxsId gxsId)\n\t{\n\t\treturn identityService.findByGxsId(gxsId).orElse(null);\n\t}\n\n\tpublic void fetchGxsGroups(PeerConnection peerConnection, Set<GxsId> gxsIds)\n\t{\n\t\tsynchronized (pendingGxsIds)\n\t\t{\n\t\t\tvar existing = identityService.findAll(gxsIds).stream()\n\t\t\t\t\t.map(GxsGroupItem::getGxsId)\n\t\t\t\t\t.collect(Collectors.toSet());\n\t\t\tvar remaining = gxsIds.stream()\n\t\t\t\t\t.filter(gxsId -> !existing.contains(gxsId))\n\t\t\t\t\t.collect(Collectors.toSet());\n\t\t\tif (!remaining.isEmpty())\n\t\t\t{\n\t\t\t\tvar pendingMap = pendingGxsIds.getOrDefault(peerConnection.getLocation().getId(), ConcurrentHashMap.newKeySet());\n\t\t\t\tpendingMap.addAll(gxsIds);\n\t\t\t\tpendingGxsIds.put(peerConnection.getLocation().getId(), pendingMap);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void setAsFriend(Set<GxsId> gxsIds)\n\t{\n\t\tsynchronized (pendingGxsIds)\n\t\t{\n\t\t\tvar remaining = setExistingAsFriend(gxsIds);\n\t\t\tfriendGxsIds.addAll(remaining);\n\t\t}\n\t}\n\n\tvoid requestGxsIds()\n\t{\n\t\tsynchronized (pendingGxsIds)\n\t\t{\n\t\t\tpendingGxsIds.forEach((locationId, gxsIds) -> {\n\t\t\t\tvar gxsIdsToGet = gxsIds.stream().limit(MAXIMUM_IDS_PER_LOCATION).toList();\n\t\t\t\tvar peerConnection = peerConnectionManager.getPeerByLocation(locationId);\n\t\t\t\tif (peerConnection != null)\n\t\t\t\t{\n\t\t\t\t\tidentityRsService.requestGxsGroups(peerConnection, gxsIdsToGet);\n\t\t\t\t\tgxsIdsToGet.forEach(gxsIds::remove); // XXX: if the peer is not there anymore, we should try to get it from other peers...\n\t\t\t\t}\n\t\t\t});\n\n\t\t\t// Remove all entries with empty sets\n\t\t\tpendingGxsIds.entrySet().removeIf(entry -> entry.getValue().isEmpty());\n\n\t\t\t// Set peer identities as friends\n\t\t\tfriendGxsIds.clear();\n\t\t\tfriendGxsIds.addAll(setExistingAsFriend(friendGxsIds));\n\t\t}\n\t}\n\n\tprivate Set<GxsId> setExistingAsFriend(Set<GxsId> gxsIds)\n\t{\n\t\tvar existing = identityService.findAll(gxsIds);\n\t\tvar convertible = existing.stream()\n\t\t\t\t.filter(identityGroupItem -> identityGroupItem.getType() == Type.OTHER)\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tconvertible.forEach(identityGroupItem -> {\n\t\t\tidentityGroupItem.setType(Type.FRIEND);\n\t\t\tidentityRsService.saveIdentity(identityGroupItem);\n\t\t});\n\n\t\tvar existingGxsIds = existing.stream()\n\t\t\t\t.map(GxsGroupItem::getGxsId)\n\t\t\t\t.collect(Collectors.toSet());\n\t\treturn gxsIds.stream()\n\t\t\t\t.filter(gxsId -> !existingGxsIds.contains(gxsId))\n\t\t\t\t.collect(Collectors.toSet());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/identity/IdentityReputation.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\n\n/**\n * Class to handle identity's reputational score. This mostly depends on the identity's affinity to a profile,\n * our friends' opinion of the identity and our own opinion.\n */\nfinal class IdentityReputation\n{\n\t/**\n\t * Identity's profile is known to us. This gives a high score.\n\t */\n\tprivate static final int PROFILE_KNOWN_SCORE = 50;\n\n\t/**\n\t * Identity is linked to a profile. This gives a middle score.\n\t */\n\tprivate static final int PROFILE_UNKNOWN_SCORE = 20;\n\n\t/**\n\t * Identity is not linked to a profile. This gives the lowest score.\n\t */\n\tprivate static final int ANONYMOUS_SCORE = 5;\n\n\tprivate IdentityReputation()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Updates the identity reputation score.\n\t *\n\t * @param identity      the identity to update\n\t * @param profileLinked if the identity is linked to a profile\n\t * @param profileKnown  if the identity's profile is known to us\n\t */\n\tpublic static void updateScore(IdentityGroupItem identity, boolean profileLinked, boolean profileKnown)\n\t{\n\t\tint identityScore;\n\n\t\tif (profileLinked)\n\t\t{\n\t\t\tif (profileKnown)\n\t\t\t{\n\t\t\t\tidentityScore = PROFILE_KNOWN_SCORE;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tidentityScore = PROFILE_UNKNOWN_SCORE;\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tidentityScore = ANONYMOUS_SCORE;\n\t\t}\n\t\tidentity.setIdentityScore(identityScore);\n\t\tidentity.setOverallScore(identityScore + identity.getOwnOpinion() + identity.getPeerOpinion());\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/identity/IdentityRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.gxs.GxsCircleType;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.database.model.gxs.GxsPrivacyFlags;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.ResourceCreationState;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.app.util.GxsUtils;\nimport io.xeres.app.xrs.common.CommentMessageItem;\nimport io.xeres.app.xrs.common.VoteMessageItem;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.gxs.GxsAuthentication;\nimport io.xeres.app.xrs.service.gxs.GxsHelperService;\nimport io.xeres.app.xrs.service.gxs.GxsRsService;\nimport io.xeres.app.xrs.service.gxs.GxsTransactionManager;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.dto.identity.IdentityConstants;\nimport io.xeres.common.gxs.GxsGroupConstants;\nimport io.xeres.common.id.*;\nimport io.xeres.common.identity.Type;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.ExecutorUtils;\nimport jakarta.persistence.EntityNotFoundException;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.security.InvalidKeyException;\nimport java.security.KeyPair;\nimport java.security.SignatureException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.app.service.ResourceCreationState.*;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.CHILD_NEEDS_AUTHOR;\nimport static io.xeres.app.xrs.service.gxs.GxsAuthentication.Flags.ROOT_NEEDS_AUTHOR;\nimport static io.xeres.app.xrs.service.identity.ValidationState.*;\nimport static io.xeres.common.protocol.xrs.RsServiceType.GXS_IDENTITY;\n\n@Component\npublic class IdentityRsService extends GxsRsService<IdentityGroupItem, GxsMessageItem>\n{\n\tprivate static final Duration PENDING_VALIDATION_START = Duration.ofSeconds(60);\n\tprivate static final Duration PENDING_VALIDATION_DELAY = Duration.ofSeconds(2);\n\tprivate static final Duration PENDING_VALIDATION_FULL_QUERY_DELAY = Duration.ofSeconds(60);\n\n\tprivate static final int PENDING_IDENTITIES_MAX = 32;\n\n\tprivate ScheduledExecutorService executorService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\tprivate final Queue<IdentityGroupItem> pendingIdentities = new ArrayDeque<>(PENDING_IDENTITIES_MAX);\n\tprivate Instant lastFullQuery = Instant.EPOCH;\n\n\tprivate final IdentityService identityService;\n\tprivate final SettingsService settingsService;\n\tprivate final ProfileService profileService;\n\tprivate final GxsHelperService<IdentityGroupItem, GxsMessageItem> gxsHelperService;\n\tprivate final ContactNotificationService contactNotificationService;\n\n\tpublic IdentityRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, GxsTransactionManager gxsTransactionManager, DatabaseSessionManager databaseSessionManager, IdentityService identityService, SettingsService settingsService, ProfileService profileService, IdentityManager identityManager, GxsHelperService<IdentityGroupItem, GxsMessageItem> gxsHelperService, ContactNotificationService contactNotificationService)\n\t{\n\t\tsuper(rsServiceRegistry, peerConnectionManager, gxsTransactionManager, databaseSessionManager, identityManager, gxsHelperService);\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.identityService = identityService;\n\t\tthis.settingsService = settingsService;\n\t\tthis.profileService = profileService;\n\t\tthis.gxsHelperService = gxsHelperService;\n\t\tthis.contactNotificationService = contactNotificationService;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn GXS_IDENTITY;\n\t}\n\n\t@Override\n\tprotected GxsAuthentication getAuthentication()\n\t{\n\t\treturn new GxsAuthentication.Builder()\n\t\t\t\t.withRequirements(EnumSet.of(ROOT_NEEDS_AUTHOR, CHILD_NEEDS_AUTHOR))\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tsuper.initialize();\n\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::checkForProfileValidation,\n\t\t\t\tgetInitPriority().getMaxTime() + PENDING_VALIDATION_START.toSeconds(),\n\t\t\t\tPENDING_VALIDATION_DELAY.toSeconds());\n\t}\n\n\t@Override\n\tpublic void cleanup()\n\t{\n\t\tsuper.cleanup();\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\tprivate void checkForProfileValidation()\n\t{\n\t\tvar identity = pendingIdentities.poll();\n\t\tif (identity == null)\n\t\t{\n\t\t\t// Search for identities not validated yet\n\t\t\tvar now = Instant.now();\n\t\t\tif (lastFullQuery.isBefore(now))\n\t\t\t{\n\t\t\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t\t\t{\n\t\t\t\t\tpendingIdentities.addAll(identityService.findIdentitiesToValidate(PENDING_IDENTITIES_MAX));\n\t\t\t\t\tlastFullQuery = now.plus(PENDING_VALIDATION_FULL_QUERY_DELAY);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t\t{\n\t\t\t\tvar validationResult = validate(identity);\n\n\t\t\t\tswitch (validationResult.validationState())\n\t\t\t\t{\n\t\t\t\t\tcase VALID ->\n\t\t\t\t\t{\n\t\t\t\t\t\tIdentityReputation.updateScore(identity, true, true);\n\t\t\t\t\t\tidentity.setNextValidation(null);\n\t\t\t\t\t\tlinkWithProfileIfFound(identity, validationResult.pgpIdentifier());\n\t\t\t\t\t\tidentityService.save(identity);\n\t\t\t\t\t\tcontactNotificationService.addOrUpdateIdentities(List.of(identity));\n\t\t\t\t\t}\n\t\t\t\t\tcase INVALID ->\n\t\t\t\t\t{\n\t\t\t\t\t\tidentityService.delete(identity);\n\t\t\t\t\t\tcontactNotificationService.removeIdentities(List.of(identity)); // This might be re-added immediately by discovery if it's on a friend. RS has the same problem\n\t\t\t\t\t}\n\t\t\t\t\tcase NOT_FOUND ->\n\t\t\t\t\t{\n\t\t\t\t\t\tIdentityReputation.updateScore(identity, true, false);\n\t\t\t\t\t\tidentity.computeNextValidationAttempt();\n\t\t\t\t\t\tidentityService.save(identity);\n\t\t\t\t\t\tcontactNotificationService.addOrUpdateIdentities(List.of(identity));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate ValidationResult validate(IdentityGroupItem identity)\n\t{\n\t\tvar pgpId = PGP.getIssuer(identity.getProfileSignature());\n\t\tif (pgpId == 0)\n\t\t{\n\t\t\tlog.error(\"Found anonymous signature. Brute forcing it is not supported.\");\n\t\t\treturn new ValidationResult(INVALID, pgpId);\n\t\t}\n\n\t\tvar profile = profileService.findProfileByPgpIdentifier(pgpId).orElse(null);\n\t\tif (profile == null)\n\t\t{\n\t\t\tlog.debug(\"PGP profile not found for identity {}, retrying later\", identity);\n\t\t\treturn new ValidationResult(NOT_FOUND, pgpId);\n\t\t}\n\n\t\tvar computedHash = makeProfileHash(identity.getGxsId(), profile.getProfileFingerprint());\n\t\tif (!identity.getProfileHash().equals(computedHash))\n\t\t{\n\t\t\tlog.error(\"Wrong profile hash for identity {}\", identity);\n\t\t\treturn new ValidationResult(INVALID, pgpId);\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tPGP.verify(PGP.getPGPPublicKey(profile.getPgpPublicKeyData()), Objects.requireNonNull(identity.getProfileSignature()), new ByteArrayInputStream(computedHash.getBytes()));\n\t\t\tlog.debug(\"Successful PGP profile validation for identity {}\", identity);\n\t\t}\n\t\tcatch (IOException | SignatureException | PGPException | InvalidKeyException | NullPointerException e)\n\t\t{\n\t\t\tlog.error(\"Profile signature verification failed for identity {}: {}\", identity, e.getMessage());\n\t\t\treturn new ValidationResult(INVALID, pgpId);\n\t\t}\n\t\treturn new ValidationResult(VALID, pgpId);\n\t}\n\n\tprivate void linkWithProfileIfFound(IdentityGroupItem identity, long pgpId)\n\t{\n\t\tprofileService.findProfileByPgpIdentifier(pgpId).ifPresent(identity::setProfile);\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tsuper.handleItem(sender, item); // This is required for the @Transactional to work\n\t}\n\n\t@Override\n\tprotected List<IdentityGroupItem> onAvailableGroupListRequest(PeerConnection recipient)\n\t{\n\t\treturn identityService.findAllSubscribed();\n\t}\n\n\t@Override\n\tprotected Set<GxsId> onAvailableGroupListResponse(Map<GxsId, Instant> ids)\n\t{\n\t\t// From the received list, we keep all identities that have a more recent publishing date than those\n\t\t// we already have. If it's a new identity, we don't want it.\n\t\tvar existingMap = identityService.findAll(ids.keySet()).stream()\n\t\t\t\t.collect(Collectors.toMap(GxsGroupItem::getGxsId, GxsGroupItem::getPublished));\n\n\t\tids.entrySet().removeIf(gxsIdInstantEntry -> {\n\t\t\tvar existing = existingMap.get(gxsIdInstantEntry.getKey());\n\t\t\treturn existing == null || !gxsIdInstantEntry.getValue().isAfter(existing);\n\t\t});\n\t\treturn ids.keySet();\n\t}\n\n\t@Override\n\tprotected List<IdentityGroupItem> onGroupListRequest(Set<GxsId> ids)\n\t{\n\t\treturn identityService.findAll(ids);\n\t}\n\n\t@Override\n\tprotected boolean onGroupReceived(IdentityGroupItem identityGroupItem)\n\t{\n\t\tlog.debug(\"Saving id {}\", identityGroupItem.getGxsId());\n\t\t// XXX: important! there should be some checks to make sure there's no malicious overwrite (probably a simple validation should do as id == fingerprint of key)\n\t\tidentityGroupItem.setSubscribed(true);\n\t\tif (identityGroupItem.getDiffusionFlags().contains(GxsPrivacyFlags.SIGNED_ID))\n\t\t{\n\t\t\tidentityGroupItem.setNextValidation(Instant.now());\n\t\t}\n\t\treturn true;\n\t}\n\n\t@Override\n\tprotected void onGroupsSaved(List<IdentityGroupItem> items)\n\t{\n\t\t// We only send the notification for contacts that don't require a validation.\n\t\t// The others will appear upon validation (or be deleted if they're not validated).\n\t\tvar itemsToNotify = items.stream()\n\t\t\t\t.filter(identityGroupItem -> !identityGroupItem.getDiffusionFlags().contains(GxsPrivacyFlags.SIGNED_ID))\n\t\t\t\t.toList();\n\t\tcontactNotificationService.addOrUpdateIdentities(itemsToNotify);\n\t}\n\n\t@Override\n\tprotected List<GxsMessageItem> onPendingMessageListRequest(PeerConnection recipient, GxsId gxsId, Instant since)\n\t{\n\t\treturn Collections.emptyList();\n\t}\n\n\t@Override\n\tprotected List<GxsMessageItem> onMessageListRequest(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn Collections.emptyList();\n\t}\n\n\t@Override\n\tprotected List<MsgId> onMessageListResponse(GxsId gxsId, Set<MsgId> msgIds)\n\t{\n\t\treturn Collections.emptyList();\n\t}\n\n\t@Override\n\tprotected boolean onMessageReceived(GxsMessageItem item)\n\t{\n\t\treturn false; // we don't receive messages\n\t}\n\n\t@Override\n\tprotected void onMessagesSaved(List<GxsMessageItem> items)\n\t{\n\t\t// nothing to do since we don't receive them\n\t}\n\n\t@Override\n\tprotected boolean onCommentReceived(CommentMessageItem item)\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tprotected void onCommentsSaved(List<CommentMessageItem> items)\n\t{\n\t\t// Nothing to do\n\t}\n\n\t@Override\n\tprotected boolean onVoteReceived(VoteMessageItem item)\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tprotected void onVotesSaved(List<VoteMessageItem> items)\n\t{\n\t\t// Nothing to do\n\t}\n\n\t@Override\n\tprotected void syncMessages(PeerConnection recipient)\n\t{\n\t\t// Nothing to do\n\t}\n\n\t@Transactional\n\tpublic ResourceCreationState generateOwnIdentity(String name, boolean signed)\n\t{\n\t\tif (!settingsService.isOwnProfilePresent())\n\t\t{\n\t\t\tlog.error(\"Cannot create an identity without a profile; Create a profile first\");\n\t\t\treturn FAILED;\n\t\t}\n\t\tif (!settingsService.hasOwnLocation())\n\t\t{\n\t\t\tlog.error(\"Cannot create an identity without a location; Create a location first\");\n\t\t\treturn FAILED;\n\t\t}\n\n\t\tif (identityService.hasOwnIdentity())\n\t\t{\n\t\t\treturn ALREADY_EXISTS;\n\t\t}\n\n\t\tvar gxsIdGroupItem = createGroup(name, false);\n\t\ttry\n\t\t{\n\t\t\tcreateOwnIdentity(gxsIdGroupItem, signed);\n\t\t}\n\t\tcatch (PGPException | IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't generate identity: {}\", e.getMessage());\n\t\t\treturn FAILED;\n\t\t}\n\t\treturn CREATED;\n\t}\n\n\t@Transactional\n\tpublic long createOwnIdentity(String name, KeyPair keyPair) throws PGPException, IOException\n\t{\n\t\tvar gxsIdGroupItem = createGroup(name, keyPair, null);\n\t\treturn createOwnIdentity(gxsIdGroupItem, true);\n\t}\n\n\tprivate long createOwnIdentity(IdentityGroupItem gxsIdGroupItem, boolean signed) throws PGPException, IOException\n\t{\n\t\tgxsIdGroupItem.setType(Type.OWN);\n\n\t\tgxsIdGroupItem.setCircleType(GxsCircleType.PUBLIC);\n\n\t\tlog.debug(\"Own identity's GxsId: {}\", gxsIdGroupItem.getGxsId());\n\n\t\tif (signed)\n\t\t{\n\t\t\tvar ownProfile = profileService.getOwnProfile();\n\t\t\tcomputeHashAndSignature(gxsIdGroupItem, ownProfile);\n\t\t\tgxsIdGroupItem.setProfile(ownProfile);\n\n\t\t\t// This is because of some backward compatibility, ideally it should be PUBLIC | REAL_ID\n\t\t\t// PRIVATE is equal to REAL_ID_deprecated\n\t\t\tgxsIdGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PRIVATE, GxsPrivacyFlags.SIGNED_ID));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tgxsIdGroupItem.setDiffusionFlags(EnumSet.of(GxsPrivacyFlags.PUBLIC));\n\t\t\t// XXX: what should the serviceString have?\n\t\t}\n\n\t\tgxsIdGroupItem.setSubscribed(true);\n\n\t\treturn saveIdentity(gxsIdGroupItem, true).getId();\n\t}\n\n\t/**\n\t * Fixes a profile signature. Xeres used to generate bugged signatures because of a mistake (upper case GxsId instead of lowercase).\n\t * While RS will apparently accept them normally, Xeres will delete them.\n\t */\n\t@Transactional\n\tpublic void fixOwnProfile() throws PGPException, IOException\n\t{\n\t\tif (!profileService.hasOwnProfile() || !identityService.hasOwnIdentity())\n\t\t{\n\t\t\treturn; // Nothing to do. There's no profile/identity yet.\n\t\t}\n\t\tvar ownProfile = profileService.getOwnProfile();\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\townIdentity.setProfile(ownProfile);\n\t\tcomputeHashAndSignature(ownIdentity, ownProfile);\n\t\tsaveIdentity(ownIdentity, true);\n\t}\n\n\t/**\n\t * Fixes an identity signature. Xeres did the same as RS by serializing the service string with the identity.\n\t * Since this string is for local data, this was wrong. Removing it requires recomputing the signatures, though.\n\t */\n\t@Transactional\n\tpublic void fixOwnIdentity()\n\t{\n\t\tif (!identityService.hasOwnIdentity())\n\t\t{\n\t\t\treturn; // Nothing to do. There's no identity yet.\n\t\t}\n\t\tvar ownIdentity = identityService.getOwnIdentity();\n\t\townIdentity.updatePublished();\n\t\tsaveIdentity(ownIdentity, true);\n\t}\n\n\tprivate void computeHashAndSignature(IdentityGroupItem gxsIdGroupItem, Profile profile) throws PGPException, IOException\n\t{\n\t\tvar hash = makeProfileHash(gxsIdGroupItem.getGxsId(), profile.getProfileFingerprint());\n\t\tgxsIdGroupItem.setProfileHash(hash);\n\t\tgxsIdGroupItem.setProfileSignature(makeProfileSignature(PGP.getPGPSecretKey(settingsService.getSecretProfileKey()), hash));\n\t}\n\n\t@Transactional\n\tpublic IdentityGroupItem saveIdentity(IdentityGroupItem identityGroupItem)\n\t{\n\t\treturn saveIdentity(identityGroupItem, false);\n\t}\n\n\tprivate IdentityGroupItem saveIdentity(IdentityGroupItem identityGroupItem, boolean updateGroup)\n\t{\n\t\tsignGroupIfNeeded(identityGroupItem);\n\t\tvar savedIdentity = identityService.save(identityGroupItem);\n\t\tif (updateGroup)\n\t\t{\n\t\t\tgxsHelperService.setLastServiceGroupsUpdateNow(RsServiceType.GXS_IDENTITY);\n\t\t}\n\t\treturn savedIdentity;\n\t}\n\n\t@Transactional\n\tpublic IdentityGroupItem saveOwnIdentityImage(long id, MultipartFile file) throws IOException\n\t{\n\t\tif (id != IdentityConstants.OWN_IDENTITY_ID)\n\t\t{\n\t\t\tthrow new EntityNotFoundException(\"Identity \" + id + \" is not our own\");\n\t\t}\n\n\t\tif (file == null || file.isEmpty())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Avatar image is empty\");\n\t\t}\n\n\t\tvar identity = identityService.findById(id).orElseThrow();\n\n\t\tidentity.setImage(GxsUtils.getScaledGroupImage(file, GxsGroupConstants.IMAGE_SIDE_SIZE));\n\t\tidentity.updatePublished();\n\n\t\treturn saveIdentity(identity, true);\n\t}\n\n\t@Transactional\n\tpublic IdentityGroupItem deleteOwnIdentityImage(long id)\n\t{\n\t\tif (id != IdentityConstants.OWN_IDENTITY_ID)\n\t\t{\n\t\t\tthrow new EntityNotFoundException(\"Identity \" + id + \" is not our own\");\n\t\t}\n\n\t\tvar identity = identityService.findById(id).orElseThrow();\n\t\tidentity.setImage(null);\n\t\tidentity.updatePublished();\n\n\t\treturn saveIdentity(identity, true);\n\t}\n\n\t@Override\n\tpublic void shutdown()\n\t{\n\t\tcontactNotificationService.shutdown();\n\t}\n\n\tstatic Sha1Sum makeProfileHash(GxsId gxsId, ProfileFingerprint fingerprint)\n\t{\n\t\tvar gxsIdAsciiUpper = Id.toAsciiBytes(gxsId);\n\n\t\tvar md = new Sha1MessageDigest();\n\t\tmd.update(gxsIdAsciiUpper);\n\t\tmd.update(fingerprint.getBytes());\n\t\treturn md.getSum();\n\t}\n\n\tprivate static byte[] makeProfileSignature(PGPSecretKey pgpSecretKey, Sha1Sum hashToSign) throws PGPException, IOException\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\t\tPGP.sign(pgpSecretKey, new ByteArrayInputStream(hashToSign.getBytes()), out, PGP.Armor.NONE);\n\t\treturn out.toByteArray();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/identity/ValidationResult.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nrecord ValidationResult(ValidationState validationState, long pgpIdentifier)\n{\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/identity/ValidationState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nenum ValidationState\n{\n\tVALID,\n\tINVALID,\n\tNOT_FOUND\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/identity/item/IdentityGroupItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.database.model.gxs.GxsGroupItem;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.TlvType;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.identity.Type;\nimport io.xeres.common.util.ByteUnitUtils;\nimport jakarta.persistence.*;\nimport org.apache.commons.lang3.ArrayUtils;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\n\n@Entity(name = \"identity_group\")\npublic class IdentityGroupItem extends GxsGroupItem\n{\n\t@Transient\n\tpublic static final IdentityGroupItem EMPTY = new IdentityGroupItem();\n\n\t@ManyToOne(fetch = FetchType.LAZY)\n\t@JoinColumn(name = \"profile_id\")\n\tprivate Profile profile;\n\n\t@Embedded\n\t@AttributeOverride(name = \"identifier\", column = @Column(name = \"profile_hash\"))\n\tprivate Sha1Sum profileHash; // hash of the gxsId + public key\n\tprivate byte[] profileSignature; // PGP id is in there\n\n\tprivate Instant nextValidation;\n\n\t@Transient\n\tprivate List<String> recognitionTags = new ArrayList<>(); // not used (but serialized)\n\n\tprivate byte[] image;\n\n\tprivate Type type = Type.OTHER;\n\n\tprivate Instant lastUsage;\n\n\tprivate int overallScore = 5;\n\tprivate int identityScore = 5;\n\tprivate int ownOpinion;\n\tprivate int peerOpinion;\n\n\tprivate int validationAttempt;\n\tprivate Instant lastValidation;\n\n\t@Transient\n\tprivate boolean oldVersion; // Needed because RS added image later, and it would break signature verification otherwise\n\n\tpublic IdentityGroupItem()\n\t{\n\t}\n\n\tpublic IdentityGroupItem(GxsId gxsId, String name)\n\t{\n\t\tsetGxsId(gxsId);\n\t\tsetName(name);\n\t\tupdatePublished();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\tpublic void computeNextValidationAttempt()\n\t{\n\t\tsetValidationAttempt(getValidationAttempt() + 1);\n\t\tsetLastValidation(Instant.now());\n\t\tsetNextValidation(getLastValidation().plus(Duration.ofDays(Math.min(getValidationAttempt(), 30))));\n\t}\n\n\tpublic Profile getProfile()\n\t{\n\t\treturn profile;\n\t}\n\n\tpublic void setProfile(Profile profile)\n\t{\n\t\tthis.profile = profile;\n\t}\n\n\tpublic Sha1Sum getProfileHash()\n\t{\n\t\treturn profileHash;\n\t}\n\n\tpublic void setProfileHash(Sha1Sum profileHash)\n\t{\n\t\tthis.profileHash = profileHash;\n\t}\n\n\tpublic byte[] getProfileSignature()\n\t{\n\t\treturn profileSignature;\n\t}\n\n\tpublic void setProfileSignature(byte[] profileSignature)\n\t{\n\t\tthis.profileSignature = ArrayUtils.isNotEmpty(profileSignature) ? profileSignature : null;\n\t}\n\n\tpublic Instant getNextValidation()\n\t{\n\t\treturn nextValidation;\n\t}\n\n\tpublic void setNextValidation(Instant nextValidation)\n\t{\n\t\tthis.nextValidation = nextValidation;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn image != null;\n\t}\n\n\tpublic byte[] getImage()\n\t{\n\t\treturn image;\n\t}\n\n\tpublic void setImage(byte[] image)\n\t{\n\t\tif (ArrayUtils.isNotEmpty(image))\n\t\t{\n\t\t\tthis.image = image;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.image = null;\n\t\t}\n\t}\n\n\tpublic Instant getLastUsage()\n\t{\n\t\treturn lastUsage;\n\t}\n\n\tpublic void setLastUsage(Instant lastUsage)\n\t{\n\t\tthis.lastUsage = lastUsage;\n\t}\n\n\tpublic int getOverallScore()\n\t{\n\t\treturn overallScore;\n\t}\n\n\tpublic void setOverallScore(int overallScore)\n\t{\n\t\tthis.overallScore = overallScore;\n\t}\n\n\tpublic int getIdentityScore()\n\t{\n\t\treturn identityScore;\n\t}\n\n\tpublic void setIdentityScore(int identityScore)\n\t{\n\t\tthis.identityScore = identityScore;\n\t}\n\n\tpublic int getOwnOpinion()\n\t{\n\t\treturn ownOpinion;\n\t}\n\n\tpublic void setOwnOpinion(int ownOpinion)\n\t{\n\t\tthis.ownOpinion = ownOpinion;\n\t}\n\n\tpublic int getPeerOpinion()\n\t{\n\t\treturn peerOpinion;\n\t}\n\n\tpublic void setPeerOpinion(int peerOpinion)\n\t{\n\t\tthis.peerOpinion = peerOpinion;\n\t}\n\n\tpublic int getValidationAttempt()\n\t{\n\t\treturn validationAttempt;\n\t}\n\n\tpublic void setValidationAttempt(int validationAttempt)\n\t{\n\t\tthis.validationAttempt = validationAttempt;\n\t}\n\n\tpublic Instant getLastValidation()\n\t{\n\t\treturn lastValidation;\n\t}\n\n\tpublic void setLastValidation(Instant lastValidation)\n\t{\n\t\tthis.lastValidation = lastValidation;\n\t}\n\n\tpublic Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\t@Override\n\tpublic int writeDataObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, profileHash, Sha1Sum.class);\n\t\tsize += serialize(buf, TlvType.STR_SIGN, profileSignature);\n\t\tsize += serialize(buf, TlvType.SET_RECOGN, recognitionTags);\n\t\tif (!oldVersion)\n\t\t{\n\t\t\tsize += serialize(buf, TlvType.IMAGE, image);\n\t\t}\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readDataObject(ByteBuf buf)\n\t{\n\t\tprofileHash = (Sha1Sum) deserializeIdentifier(buf, Sha1Sum.class);\n\t\tsetProfileSignature((byte[]) deserialize(buf, TlvType.STR_SIGN));\n\t\t//noinspection unchecked\n\t\trecognitionTags = (List<String>) deserialize(buf, TlvType.SET_RECOGN);\n\n\t\tif (buf.isReadable())\n\t\t{\n\t\t\tsetImage((byte[]) deserialize(buf, TlvType.IMAGE));\n\t\t}\n\t\telse\n\t\t{\n\t\t\toldVersion = true;\n\t\t}\n\t}\n\n\t@Override\n\tpublic IdentityGroupItem clone()\n\t{\n\t\treturn (IdentityGroupItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"IdentityGroupItem{\" +\n\t\t\t\t\"name=\" + getName() +\n\t\t\t\t\", gxsId=\" + getGxsId() +\n\t\t\t\t\", profile=\" + profile +\n\t\t\t\t\", profileHash=\" + profileHash +\n\t\t\t\t\", profileSignature=\" + (profileSignature != null ? (\"yes, \" + ByteUnitUtils.fromBytes(profileSignature.length)) : \"no\") +\n\t\t\t\t\", nextValidation=\" + nextValidation +\n\t\t\t\t\", recognitionTags=\" + recognitionTags +\n\t\t\t\t\", image=\" + (image != null ? (\"yes, \" + ByteUnitUtils.fromBytes(image.length)) : \"no\") +\n\t\t\t\t\", type=\" + type +\n\t\t\t\t\", oldVersion=\" + oldVersion +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/rtt/RttRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.rtt;\n\nimport io.xeres.app.application.events.PeerDisconnectedEvent;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.rtt.item.RttPingItem;\nimport io.xeres.app.xrs.service.rtt.item.RttPongItem;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.rest.statistics.RttPeer;\nimport io.xeres.common.rest.statistics.RttStatisticsResponse;\nimport org.apache.commons.collections4.queue.CircularFifoQueue;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.common.protocol.xrs.RsServiceType.RTT;\n\n@Component\npublic class RttRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RttRsService.class);\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\n\tprivate static final int KEY_COUNTER = 1;\n\tpublic static final int KEY_RTT = 2;\n\n\tprivate final Map<Long, CircularFifoQueue<Duration>> peers = new ConcurrentHashMap<>();\n\n\tpublic RttRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn RTT;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.NORMAL;\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.scheduleAtFixedRate(\n\t\t\t\t() -> peerConnectionManager.writeItem(peerConnection, new RttPingItem(getCounter(peerConnection), get64bitsTimeStamp()), this),\n\t\t\t\t0,\n\t\t\t\t10,\n\t\t\t\tTimeUnit.SECONDS);\n\n\t\tvar means = peers.computeIfAbsent(peerConnection.getLocation().getId(), _ -> new CircularFifoQueue<>(20));\n\t\tmeans.clear();\n\t}\n\n\t@EventListener\n\tpublic void onPeerDisconnectedEvent(PeerDisconnectedEvent event)\n\t{\n\t\tpeers.remove(event.id());\n\t}\n\n\tprivate int getCounter(PeerConnection peerConnection)\n\t{\n\t\tvar counter = (int) peerConnection.getServiceData(this, KEY_COUNTER).orElse(1);\n\t\tpeerConnection.putServiceData(this, KEY_COUNTER, ++counter);\n\t\treturn counter;\n\t}\n\n\tprivate static long get64bitsTimeStamp()\n\t{\n\t\tvar now = Instant.now().truncatedTo(ChronoUnit.MICROS);\n\n\t\treturn (now.getEpochSecond() << 32) + now.getNano() / 1_000L;\n\t}\n\n\tprivate static Instant getInstantFromTimestamp(long timestamp)\n\t{\n\t\treturn Instant.ofEpochSecond(timestamp >> 32 & 0xffffffffL, (timestamp & 0xffffffffL) * 1_000L);\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof RttPingItem pingItem)\n\t\t{\n\t\t\tvar pong = new RttPongItem(pingItem, get64bitsTimeStamp());\n\t\t\tpeerConnectionManager.writeItem(sender, pong, this);\n\t\t}\n\t\telse if (item instanceof RttPongItem pongItem)\n\t\t{\n\t\t\tvar now = Instant.now();\n\t\t\tvar ping = getInstantFromTimestamp(pongItem.getPingTimestamp());\n\t\t\tvar pong = getInstantFromTimestamp(pongItem.getPongTimestamp());\n\n\t\t\tvar rtt = Duration.between(ping, now);\n\t\t\tvar offset = Duration.between(pong, now.minus(rtt.dividedBy(2)));\n\t\t\tvar peerTime = now.plus(offset);\n\n\t\t\tlog.debug(\"RTT: {}, offset: {}, peerTime: {}\", rtt, offset, peerTime);\n\n\t\t\tsender.putServiceData(this, KEY_RTT, rtt.toMillis());\n\n\t\t\tvar means = peers.get(sender.getLocation().getId());\n\t\t\tmeans.add(offset);\n\n\t\t\tif (means.isAtFullCapacity())\n\t\t\t{\n\t\t\t\tvar mean = means.stream()\n\t\t\t\t\t\t.mapToLong(Duration::toMillis)\n\t\t\t\t\t\t.average()\n\t\t\t\t\t\t.orElse(0.0) / 1000.0;\n\n\t\t\t\tif (Math.abs(mean) > 120.0)\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Peer {}'s time is drifting ({} seconds)\", sender, mean);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic RttStatisticsResponse getStatistics()\n\t{\n\t\tList<RttPeer> rttPeers = new ArrayList<>(peerConnectionManager.getNumberOfPeers());\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> rttPeers.add(new RttPeer(peerConnection.getLocation().getId(), peerConnection.getLocation().getProfile().getName() + \"@\" + peerConnection.getLocation().getSafeName(), (long) peerConnection.getServiceData(this, KEY_RTT).orElse(0L))), this);\n\n\t\treturn new RttStatisticsResponse(rttPeers);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/rtt/item/RttPingItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.rtt.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class RttPingItem extends Item\n{\n\t@RsSerialized\n\tprivate int sequenceNumber;\n\n\t@RsSerialized\n\tprivate long timestamp;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic RttPingItem()\n\t{\n\t}\n\n\tpublic RttPingItem(int sequenceNumber, long timeStamp)\n\t{\n\t\tthis.sequenceNumber = sequenceNumber;\n\t\ttimestamp = timeStamp;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.RTT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\tpublic int getSequenceNumber()\n\t{\n\t\treturn sequenceNumber;\n\t}\n\n\tpublic long getTimestamp()\n\t{\n\t\treturn timestamp;\n\t}\n\n\t@Override\n\tpublic RttPingItem clone()\n\t{\n\t\treturn (RttPingItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"RttPingItem{\" +\n\t\t\t\t\"sequenceNumber=\" + sequenceNumber +\n\t\t\t\t\", pingTimeStamp=\" + timestamp +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/rtt/item/RttPongItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.rtt.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class RttPongItem extends Item\n{\n\t@RsSerialized\n\tprivate int sequenceNumber;\n\n\t@RsSerialized\n\tprivate long pingTimestamp;\n\n\t@RsSerialized\n\tprivate long pongTimestamp;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic RttPongItem()\n\t{\n\t}\n\n\tpublic RttPongItem(RttPingItem pingItem, long timeStamp)\n\t{\n\t\tsequenceNumber = pingItem.getSequenceNumber();\n\t\tpingTimestamp = pingItem.getTimestamp();\n\t\tpongTimestamp = timeStamp;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.RTT.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\tpublic long getPingTimestamp()\n\t{\n\t\treturn pingTimestamp;\n\t}\n\n\tpublic long getPongTimestamp()\n\t{\n\t\treturn pongTimestamp;\n\t}\n\n\t@Override\n\tpublic RttPongItem clone()\n\t{\n\t\treturn (RttPongItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"RttPongItem{\" +\n\t\t\t\t\"sequenceNumber=\" + sequenceNumber +\n\t\t\t\t\", pingTimeStamp=\" + pingTimestamp +\n\t\t\t\t\", pongTimeStamp=\" + pongTimestamp +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/serviceinfo/ServiceInfoRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.serviceinfo;\n\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.serviceinfo.item.ServiceInfo;\nimport io.xeres.app.xrs.service.serviceinfo.item.ServiceListItem;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\n\nimport java.util.HashMap;\nimport java.util.PriorityQueue;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Predicate;\n\nimport static io.xeres.common.protocol.xrs.RsServiceType.PACKET_SLICING_PROBE;\nimport static io.xeres.common.protocol.xrs.RsServiceType.SERVICE_INFO;\nimport static java.util.stream.Collectors.joining;\n\n@Component\npublic class ServiceInfoRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ServiceInfoRsService.class);\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final RsServiceRegistry rsServiceRegistry;\n\n\tpublic ServiceInfoRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.rsServiceRegistry = rsServiceRegistry;\n\t}\n\n\tpublic void init(PeerConnection peerConnection)\n\t{\n\t\tsendFirstServiceList(peerConnection); //XXX: if sending and receiving at the same time (5 seconds makes it happen), then it can resend back. solution? put a timer before sending back?\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn SERVICE_INFO;\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof ServiceListItem serviceListItem)\n\t\t{\n\t\t\t// RS requests services twice upon first connection (bug?)\n\t\t\tif (!sender.canSendServices())\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tvar services = new PriorityQueue<RsService>();\n\n\t\t\tserviceListItem.getServices().forEach((_, serviceInfo) ->\n\t\t\t{\n\t\t\t\tvar rsService = rsServiceRegistry.getServiceFromType(serviceInfo.getType());\n\t\t\t\tif (rsService != null)\n\t\t\t\t{\n\t\t\t\t\tsender.addService(rsService);\n\t\t\t\t\tservices.add(rsService);\n\t\t\t\t}\n\t\t\t});\n\t\t\tif (log.isDebugEnabled())\n\t\t\t{\n\t\t\t\tlog.debug(\"Enabling services {} to peer {}\", services.stream().map(rsService -> rsService.getServiceType().name()).collect(joining(\", \")), sender);\n\t\t\t}\n\t\t\tsendFirstServiceList(sender);\n\n\t\t\tinitializeServices(sender, services);\n\t\t}\n\t}\n\n\tprivate void sendFirstServiceList(PeerConnection peerConnection)\n\t{\n\t\tvar services = new HashMap<Integer, ServiceInfo>();\n\n\t\tvar allServices = rsServiceRegistry.getServices();\n\t\tallServices.stream()\n\t\t\t\t.filter(Predicate.not(rsService -> rsService.getServiceType() == PACKET_SLICING_PROBE)) // we hide this as it's not strictly a service in RS' terms\n\t\t\t\t.forEach(rsService ->\n\t\t\t\t{\n\t\t\t\t\tvar serviceType = rsService.getServiceType();\n\t\t\t\t\tvar type = 2 << 24 | rsService.getServiceType().getType() << 8;\n\t\t\t\t\tservices.put(type, new ServiceInfo(serviceType.getName(), type, rsService.getServiceType().getVersionMajor(), rsService.getServiceType().getVersionMinor()));\n\t\t\t\t});\n\n\t\tpeerConnectionManager.writeItem(peerConnection, new ServiceListItem(services), this);\n\t}\n\n\tprivate static void initializeServices(PeerConnection peerConnection, PriorityQueue<RsService> services)\n\t{\n\t\tRsService rsService;\n\n\t\twhile ((rsService = services.poll()) != null)\n\t\t{\n\t\t\tif (rsService.getInitPriority() != RsServiceInitPriority.OFF)\n\t\t\t{\n\t\t\t\tvar finalRsService = rsService;\n\t\t\t\tpeerConnection.schedule(() -> finalRsService.initialize(peerConnection),\n\t\t\t\t\t\tThreadLocalRandom.current().nextInt(rsService.getInitPriority().getMinTime(), rsService.getInitPriority().getMaxTime() + 1),\n\t\t\t\t\t\tTimeUnit.SECONDS);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/serviceinfo/item/ServiceInfo.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.serviceinfo.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\n\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.serialization.Serializer.*;\n\npublic class ServiceInfo implements RsSerializable\n{\n\tprivate String name;\n\tprivate int serviceType;\n\tprivate short versionMajor;\n\tprivate short versionMinor;\n\tprivate short minVersionMajor;\n\tprivate short minVersionMinor;\n\n\tpublic ServiceInfo()\n\t{\n\t}\n\n\tpublic ServiceInfo(String name, int serviceType, short versionMajor, short versionMinor)\n\t{\n\t\tthis.name = name;\n\t\tthis.serviceType = serviceType;\n\t\tthis.versionMajor = versionMajor;\n\t\tthis.versionMinor = versionMinor;\n\t\tminVersionMajor = versionMajor;\n\t\tminVersionMinor = versionMinor;\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += serialize(buf, name);\n\t\tsize += serialize(buf, serviceType);\n\t\tsize += serialize(buf, versionMajor);\n\t\tsize += serialize(buf, versionMinor);\n\t\tsize += serialize(buf, minVersionMajor);\n\t\tsize += serialize(buf, minVersionMinor);\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tname = deserializeString(buf);\n\t\tserviceType = deserializeInt(buf);\n\t\tversionMajor = deserializeShort(buf);\n\t\tversionMinor = deserializeShort(buf);\n\t\tminVersionMajor = deserializeShort(buf);\n\t\tminVersionMinor = deserializeShort(buf);\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic int getServiceType()\n\t{\n\t\treturn serviceType;\n\t}\n\n\tpublic int getType()\n\t{\n\t\treturn (serviceType >> 8) & 0xffff;\n\t}\n\n\tpublic short getVersionMajor()\n\t{\n\t\treturn versionMajor;\n\t}\n\n\tpublic short getVersionMinor()\n\t{\n\t\treturn versionMinor;\n\t}\n\n\tpublic short getMinVersionMajor()\n\t{\n\t\treturn minVersionMajor;\n\t}\n\n\tpublic short getMinVersionMinor()\n\t{\n\t\treturn minVersionMinor;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ServiceInfo{\" +\n\t\t\t\t\"name='\" + name + '\\'' +\n\t\t\t\t\", type=\" + serviceType +\n\t\t\t\t\", version=\" + versionMajor + \".\" + versionMinor +\n\t\t\t\t\", min=\" + minVersionMajor + \".\" + minVersionMinor +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/serviceinfo/item/ServiceListItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.serviceinfo.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class ServiceListItem extends Item\n{\n\t@RsSerialized\n\tprivate Map<Integer, ServiceInfo> services = new HashMap<>();\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ServiceListItem()\n\t{\n\t}\n\n\tpublic ServiceListItem(Map<Integer, ServiceInfo> services)\n\t{\n\t\tthis.services = services;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.SERVICE_INFO.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn 7;\n\t}\n\n\tpublic Map<Integer, ServiceInfo> getServices()\n\t{\n\t\treturn services;\n\t}\n\n\t@Override\n\tpublic ServiceListItem clone()\n\t{\n\t\treturn (ServiceListItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ServiceListItem{\" +\n\t\t\t\t\"map=\" + services.values() +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/sliceprobe/SliceProbeRsService.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.sliceprobe;\n\nimport io.xeres.app.net.peer.PeerAttribute;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\n\nimport static io.xeres.common.protocol.xrs.RsServiceType.PACKET_SLICING_PROBE;\n\n@Component\npublic class SliceProbeRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SliceProbeRsService.class);\n\n\tpublic SliceProbeRsService(RsServiceRegistry rsServiceRegistry)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn PACKET_SLICING_PROBE;\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (!Boolean.TRUE.equals(sender.getCtx().channel().attr(PeerAttribute.MULTI_PACKET).get()))\n\t\t{\n\t\t\tlog.debug(\"Received slice probe, switching to new packet format for current session\");\n\t\t\tsender.getCtx().channel().attr(PeerAttribute.MULTI_PACKET).set(true);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/sliceprobe/item/SliceProbeItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.sliceprobe.item;\n\nimport io.netty.channel.ChannelHandlerContext;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class SliceProbeItem extends Item\n{\n\tpublic static SliceProbeItem from(ChannelHandlerContext ctx)\n\t{\n\t\tvar sliceProbeItem = new SliceProbeItem();\n\t\tsliceProbeItem.setOutgoing(ctx.alloc(), null);\n\t\treturn sliceProbeItem;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.PACKET_SLICING_PROBE.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 0xCC;\n\t}\n\n\t@Override\n\tpublic SliceProbeItem clone()\n\t{\n\t\treturn (SliceProbeItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"SliceProbeItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/ChatStatus.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\n\npublic enum ChatStatus\n{\n\t// Order and values matter\n\tOFFLINE,\n\tAWAY,\n\tBUSY,\n\tONLINE,\n\tINACTIVE // RS uses that like \"Away\" except it's automatic. We don't use it.\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/GetIdleTime.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\npublic interface GetIdleTime\n{\n\tint getIdleTime();\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/IdleChecker.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class IdleChecker\n{\n\tprivate final GetIdleTime getIdleTime;\n\n\tpublic IdleChecker(@SuppressWarnings(\"SpringJavaInjectionPointsAutowiringInspection\") GetIdleTime getIdleTime)\n\t{\n\t\tthis.getIdleTime = getIdleTime;\n\t}\n\n\tpublic int getIdleTime()\n\t{\n\t\treturn getIdleTime.getIdleTime();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/StatusRsService.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.MessageService;\nimport io.xeres.app.service.notification.availability.AvailabilityNotificationService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceInitPriority;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.status.item.StatusItem;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.concurrent.TimeUnit;\n\nimport static io.xeres.common.message.MessagePath.chatPrivateDestination;\nimport static io.xeres.common.message.MessageType.CHAT_AVAILABILITY;\nimport static io.xeres.common.protocol.xrs.RsServiceType.STATUS;\n\n@Component\npublic class StatusRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(StatusRsService.class);\n\n\tprivate Availability availability = Availability.AVAILABLE;\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final MessageService messageService;\n\tprivate final LocationService locationService;\n\tprivate final AvailabilityNotificationService availabilityNotificationService;\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\n\tprivate boolean locked;\n\n\tpublic StatusRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, MessageService messageService, LocationService locationService, AvailabilityNotificationService availabilityNotificationService, DatabaseSessionManager databaseSessionManager)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.messageService = messageService;\n\t\tthis.locationService = locationService;\n\t\tthis.availabilityNotificationService = availabilityNotificationService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn STATUS;\n\t}\n\n\t@Override\n\tpublic RsServiceInitPriority getInitPriority()\n\t{\n\t\treturn RsServiceInitPriority.NORMAL;\n\t}\n\n\t@Override\n\tpublic void initialize(PeerConnection peerConnection)\n\t{\n\t\tpeerConnection.schedule(\n\t\t\t\t() -> peerConnectionManager.writeItem(peerConnection, new StatusItem(ChatStatus.ONLINE), this),\n\t\t\t\t0,\n\t\t\t\tTimeUnit.SECONDS);\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tif (item instanceof StatusItem statusItem)\n\t\t{\n\t\t\tlog.debug(\"Got status {} from peer {}\", statusItem.getStatus(), sender);\n\t\t\tvar newStatus = toAvailability(statusItem.getStatus());\n\t\t\tlocationService.setAvailability(sender.getLocation(), newStatus);\n\t\t\tavailabilityNotificationService.changeAvailability(sender.getLocation(), newStatus);\n\t\t\tmessageService.sendToConsumers(chatPrivateDestination(), CHAT_AVAILABILITY, sender.getLocation().getLocationIdentifier(), newStatus);\n\t\t}\n\t}\n\n\tprivate Availability toAvailability(ChatStatus status)\n\t{\n\t\treturn switch (status)\n\t\t{\n\t\t\tcase ONLINE -> Availability.AVAILABLE;\n\t\t\tcase AWAY, INACTIVE, OFFLINE -> Availability.AWAY;\n\t\t\tcase BUSY -> Availability.BUSY;\n\t\t};\n\t}\n\n\tprivate ChatStatus toChatStatus(Availability availability)\n\t{\n\t\treturn switch (availability)\n\t\t{\n\t\t\tcase AVAILABLE -> ChatStatus.ONLINE;\n\t\t\tcase AWAY -> ChatStatus.AWAY;\n\t\t\tcase BUSY -> ChatStatus.BUSY;\n\t\t\tcase OFFLINE -> ChatStatus.OFFLINE;\n\t\t};\n\t}\n\n\tpublic void changeAvailability(Availability availability)\n\t{\n\t\tlocked = false;\n\t\tchangeAvailabilityAutomatically(availability);\n\t\tlocked = availability != Availability.AVAILABLE;\n\t}\n\n\tpublic void changeAvailabilityAutomatically(Availability availability)\n\t{\n\t\tif (!locked && availability != this.availability)\n\t\t{\n\t\t\ttry (var session = new DatabaseSession(databaseSessionManager))\n\t\t\t{\n\t\t\t\tvar ownLocation = locationService.findOwnLocation().orElseThrow();\n\t\t\t\tthis.availability = availability;\n\t\t\t\tlocationService.setAvailability(ownLocation, availability);\n\t\t\t\tavailabilityNotificationService.changeAvailability(ownLocation, availability);\n\t\t\t\tpeerConnectionManager.doForAllPeers(peerConnection -> peerConnectionManager.writeItem(peerConnection, new StatusItem(toChatStatus(availability)), this), this);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeGeneric.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status.idletimer;\n\nimport io.xeres.app.xrs.service.status.GetIdleTime;\n\npublic class GetIdleTimeGeneric implements GetIdleTime\n{\n\t@Override\n\tpublic int getIdleTime()\n\t{\n\t\treturn 0;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeLinux.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status.idletimer;\n\nimport com.sun.jna.Library;\nimport com.sun.jna.Native;\nimport com.sun.jna.NativeLong;\nimport com.sun.jna.Structure;\nimport com.sun.jna.Structure.FieldOrder;\nimport com.sun.jna.platform.unix.X11;\nimport io.xeres.app.xrs.service.status.GetIdleTime;\n\npublic class GetIdleTimeLinux implements GetIdleTime\n{\n\t@SuppressWarnings(\"unused\")\n\t@FieldOrder({\"window\", \"state\", \"kind\", \"tilOrSince\", \"idle\", \"eventMask\"})\n\tpublic static class XScreenSaverInfo extends Structure\n\t{\n\t\tpublic X11.Window window;\n\t\tpublic int state;\n\t\tpublic int kind;\n\t\tpublic NativeLong tilOrSince;\n\t\tpublic NativeLong idle;\n\t\tpublic NativeLong eventMask;\n\t}\n\n\tprivate interface Xss extends Library\n\t{\n\t\tXss INSTANCE = Native.load(\"Xss\", Xss.class);\n\n\t\t@SuppressWarnings(\"UnusedReturnValue\")\n\t\tint XScreenSaverQueryInfo(X11.Display display, X11.Drawable drawable, XScreenSaverInfo xScreenSaverInfo);\n\t}\n\n\n\t@Override\n\tpublic int getIdleTime()\n\t{\n\t\tX11.Display display = null;\n\t\tX11.Window window;\n\t\tXScreenSaverInfo xScreenSaverInfo;\n\n\t\tvar idleMillis = 0L;\n\t\ttry\n\t\t{\n\t\t\tdisplay = X11.INSTANCE.XOpenDisplay(null);\n\t\t\twindow = X11.INSTANCE.XDefaultRootWindow(display);\n\t\t\txScreenSaverInfo = new XScreenSaverInfo();\n\t\t\tXss.INSTANCE.XScreenSaverQueryInfo(display, window, xScreenSaverInfo);\n\t\t\tidleMillis = xScreenSaverInfo.idle.longValue();\n\t\t}\n\t\tcatch (NoClassDefFoundError | UnsatisfiedLinkError _)\n\t\t{\n\t\t\t// No X11 library (console-only). There's no way to get idle time then.\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif (display != null)\n\t\t\t{\n\t\t\t\tX11.INSTANCE.XCloseDisplay(display);\n\t\t\t}\n\t\t}\n\t\treturn (int) (idleMillis / 1000);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeMac.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status.idletimer;\n\nimport com.sun.jna.Library;\nimport com.sun.jna.Native;\nimport io.xeres.app.xrs.service.status.GetIdleTime;\n\npublic class GetIdleTimeMac implements GetIdleTime\n{\n\tprivate interface ApplicationServices extends Library\n\t{\n\t\tApplicationServices INSTANCE = Native.load(\"ApplicationServices\", ApplicationServices.class);\n\n\t\tint kCGAnyInputEventType = ~0;\n\t\tint kCGEventSourceStateCombinedSessionState = 0;\n\n\t\tdouble CGEventSourceSecondsSinceLastEventType(int sourceStateId, int eventType);\n\t}\n\n\t@Override\n\tpublic int getIdleTime()\n\t{\n\t\tvar idleTimeSeconds = ApplicationServices.INSTANCE.CGEventSourceSecondsSinceLastEventType(\n\t\t\t\tApplicationServices.kCGEventSourceStateCombinedSessionState,\n\t\t\t\tApplicationServices.kCGAnyInputEventType);\n\t\treturn (int) idleTimeSeconds;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/idletimer/GetIdleTimeWindows.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status.idletimer;\n\nimport com.sun.jna.platform.win32.Kernel32;\nimport com.sun.jna.platform.win32.User32;\nimport com.sun.jna.platform.win32.WinUser;\nimport io.xeres.app.xrs.service.status.GetIdleTime;\n\npublic class GetIdleTimeWindows implements GetIdleTime\n{\n\t@Override\n\tpublic int getIdleTime()\n\t{\n\t\tvar lastInputInfo = new WinUser.LASTINPUTINFO();\n\t\tUser32.INSTANCE.GetLastInputInfo(lastInputInfo);\n\t\treturn (Kernel32.INSTANCE.GetTickCount() - lastInputInfo.dwTime) / 1000;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/status/item/StatusItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.status.ChatStatus;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\nimport java.time.Instant;\n\npublic class StatusItem extends Item\n{\n\t@RsSerialized\n\tprivate int sendTime;\n\n\t@RsSerialized\n\tprivate ChatStatus status;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic StatusItem()\n\t{\n\t}\n\n\tpublic StatusItem(ChatStatus status)\n\t{\n\t\tsendTime = (int) Instant.now().getEpochSecond();\n\t\tthis.status = status;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.STATUS.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\tpublic int getSendTime()\n\t{\n\t\treturn sendTime;\n\t}\n\n\tpublic ChatStatus getStatus()\n\t{\n\t\treturn status;\n\t}\n\n\t@Override\n\tpublic StatusItem clone()\n\t{\n\t\treturn (StatusItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"StatusItem{\" +\n\t\t\t\t\"sendTime=\" + sendTime +\n\t\t\t\t\", status=\" + status +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/HashInfo.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport java.time.Instant;\nimport java.util.Set;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * Keeps track of the activity for the file hashes that the turtle router is asked to monitor.\n */\nclass HashInfo\n{\n\tprivate final Set<Integer> tunnels = ConcurrentHashMap.newKeySet();\n\tprivate int lastRequest;\n\tprivate Instant lastDiggTime;\n\tprivate final TurtleRsClient client;\n\tprivate final boolean aggressiveMode; // if set, allows creation of concurrent tunnels (for example 4 tunnels to download 1 file)\n\n\t/**\n\t * Creates a HashInfo to keep track of the activity regarding a file hash, thus is usually paired with one.\n\t *\n\t * @param aggressiveMode if true, allow the use of multiple tunnels for one hash\n\t * @param client         the {@link TurtleRsClient}\n\t */\n\tpublic HashInfo(boolean aggressiveMode, TurtleRsClient client)\n\t{\n\t\tlastDiggTime = Instant.EPOCH;\n\t\tthis.client = client;\n\t\tthis.aggressiveMode = aggressiveMode;\n\t}\n\n\tpublic int getLastRequest()\n\t{\n\t\treturn lastRequest;\n\t}\n\n\tpublic void setLastRequest(int lastRequest)\n\t{\n\t\tthis.lastRequest = lastRequest;\n\t}\n\n\tpublic void addTunnel(int tunnelId)\n\t{\n\t\ttunnels.add(tunnelId);\n\t}\n\n\tpublic TurtleRsClient getClient()\n\t{\n\t\treturn client;\n\t}\n\n\tpublic Set<Integer> getTunnels()\n\t{\n\t\treturn tunnels;\n\t}\n\n\tpublic void removeTunnel(int tunnelId)\n\t{\n\t\ttunnels.remove(tunnelId);\n\t}\n\n\tpublic boolean hasTunnels()\n\t{\n\t\treturn !tunnels.isEmpty();\n\t}\n\n\tpublic Instant getLastDiggTime()\n\t{\n\t\treturn lastDiggTime;\n\t}\n\n\tpublic void setLastDiggTime(Instant lastDiggTime)\n\t{\n\t\tthis.lastDiggTime = lastDiggTime;\n\t}\n\n\tpublic boolean isAggressiveMode()\n\t{\n\t\treturn aggressiveMode;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/SearchRequest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.model.location.Location;\n\nimport java.time.Instant;\n\n/**\n * Keeps track of search requests.\n */\nclass SearchRequest\n{\n\tprivate final Location source;\n\tprivate final Instant lastUsed;\n\tprivate final int depth;\n\tprivate final String keywords;\n\tprivate int resultCount;\n\tprivate final int hitLimit;\n\tprivate TurtleRsClient client;\n\n\t/**\n\t * Creates a search request.\n\t *\n\t * @param source      where the search request came from\n\t * @param depth       depth of the search request, used to optimize tunnel length\n\t * @param keywords    search string\n\t * @param resultCount number of responses to this search request, useful to avoid spamming tunnel responses\n\t * @param hitLimit    maximum number of hits allowed for this search request\n\t */\n\tpublic SearchRequest(Location source, int depth, String keywords, int resultCount, int hitLimit)\n\t{\n\t\tthis.source = source;\n\t\tlastUsed = Instant.now();\n\t\tthis.depth = depth;\n\t\tthis.keywords = keywords;\n\t\tthis.resultCount = resultCount;\n\t\tthis.hitLimit = hitLimit;\n\t}\n\n\tpublic SearchRequest(TurtleRsClient client, Location source, int depth, String keywords, int resultCount, int hitLimit)\n\t{\n\t\tthis(source, depth, keywords, resultCount, hitLimit);\n\t\tthis.client = client;\n\t}\n\n\tpublic Location getSource()\n\t{\n\t\treturn source;\n\t}\n\n\tpublic Instant getLastUsed()\n\t{\n\t\treturn lastUsed;\n\t}\n\n\tpublic int getDepth()\n\t{\n\t\treturn depth;\n\t}\n\n\tpublic String getKeywords()\n\t{\n\t\treturn keywords;\n\t}\n\n\tpublic int getResultCount()\n\t{\n\t\treturn resultCount;\n\t}\n\n\tpublic int getHitLimit()\n\t{\n\t\treturn hitLimit;\n\t}\n\n\tpublic boolean isFull()\n\t{\n\t\treturn resultCount >= hitLimit;\n\t}\n\n\tpublic void addResultCount(int value)\n\t{\n\t\tresultCount += value;\n\t}\n\n\tpublic TurtleRsClient getClient()\n\t{\n\t\treturn client;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/Tunnel.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.time.Instant;\n\n/**\n * Keeps track of tunnels.\n */\nclass Tunnel\n{\n\tprivate final Location source;\n\tprivate final Location destination;\n\tprivate final Location virtualLocation;\n\tprivate Sha1Sum hash;\n\tprivate Instant lastUsed;\n\tprivate long transferredBytes;\n\tprivate double speedBps;\n\n\t/**\n\t * Creates a tunnel.\n\t *\n\t * @param tunnelId    the tunnel id, it will define the virtual location\n\t * @param source      where packets come from\n\t * @param destination where packets go to (might not be the final recipient, the virtual location is)\n\t * @param hash        hash of the file for this tunnel\n\t */\n\tpublic Tunnel(int tunnelId, Location source, Location destination, Sha1Sum hash)\n\t{\n\t\tthis.source = source;\n\t\tthis.destination = destination;\n\t\tthis.hash = hash;\n\t\tvirtualLocation = VirtualLocation.fromTunnel(tunnelId);\n\t\tlastUsed = Instant.now();\n\t}\n\n\tpublic Location getSource()\n\t{\n\t\treturn source;\n\t}\n\n\tpublic Location getDestination()\n\t{\n\t\treturn destination;\n\t}\n\n\tpublic Location getVirtualLocation()\n\t{\n\t\treturn virtualLocation;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic void setHash(Sha1Sum hash)\n\t{\n\t\tthis.hash = hash;\n\t}\n\n\tpublic double getSpeedBps()\n\t{\n\t\treturn speedBps;\n\t}\n\n\tpublic void setSpeedBps(double speedBps)\n\t{\n\t\tthis.speedBps = speedBps;\n\t}\n\n\tpublic void addTransferredBytes(long transferredBytes)\n\t{\n\t\tthis.transferredBytes += transferredBytes;\n\t}\n\n\tpublic Instant getLastUsed()\n\t{\n\t\treturn lastUsed;\n\t}\n\n\tpublic long getTransferredBytes()\n\t{\n\t\treturn transferredBytes;\n\t}\n\n\tpublic void clearTransferredBytes()\n\t{\n\t\ttransferredBytes = 0;\n\t}\n\n\tpublic void stamp()\n\t{\n\t\tlastUsed = Instant.now();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/TunnelProbability.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.xrs.service.turtle.item.TurtleSearchRequestItem;\nimport io.xeres.app.xrs.service.turtle.item.TurtleTunnelRequestItem;\nimport io.xeres.common.util.SecureRandomUtils;\n\nimport static io.xeres.app.xrs.service.turtle.TurtleRsService.MAX_TUNNEL_DEPTH;\n\n/**\n * Calculates probabilities of forwarding turtle tunnels.\n */\nclass TunnelProbability\n{\n\tprivate static final int TUNNEL_REQUEST_PACKET_SIZE = 50;\n\n\tprivate static final int MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND = 20;\n\n\tprivate static final int DISTANCE_SQUEEZING_POWER = 8;\n\n\tprivate static final double[] DEPTH_PEER_PROBABILITY = new double[]{1.0, 0.99, 0.9, 0.7, 0.6, 0.5, 0.4};\n\n\tprivate final int bias;\n\n\tpublic TunnelProbability()\n\t{\n\t\tbias = SecureRandomUtils.nextInt();\n\t}\n\n\t/**\n\t * Finds out if a search request subclass is forwardable. Its depth has to be lower than MAX_TUNNEL_DEPTH. There's a random\n\t * bias to let some packets pass to avoid a successful search by depth attack.\n\t *\n\t * @param item  a {@link TurtleSearchRequestItem}, not null\n\t * @return true if forwardable\n\t */\n\tpublic boolean isForwardable(TurtleSearchRequestItem item)\n\t{\n\t\treturn isForwardable(item.getRequestId(), item.getDepth());\n\t}\n\n\t/**\n\t * Finds out if a tunnel request is forwardable. Its depth has to be lower than MAX_TUNNEL_DEPTH. There's a random\n\t * bias to let some packets pass to avoid a successful search by depth attack.\n\t *\n\t * @param item  a {@link TurtleTunnelRequestItem}, not null\n\t * @return true if forwardable\n\t */\n\tpublic boolean isForwardable(TurtleTunnelRequestItem item)\n\t{\n\t\treturn isForwardable(item.getPartialTunnelId(), item.getDepth());\n\t}\n\n\tprivate boolean isForwardable(int id, int depth)\n\t{\n\t\tvar randomBypass = depth >= MAX_TUNNEL_DEPTH && (((bias ^ id) & 0x7) == 2);\n\n\t\treturn depth < MAX_TUNNEL_DEPTH || randomBypass;\n\t}\n\n\tpublic void incrementDepth(TurtleSearchRequestItem item)\n\t{\n\t\titem.setDepth(incrementDepth(item.getRequestId(), item.getDepth()));\n\t}\n\n\tpublic void incrementDepth(TurtleTunnelRequestItem item)\n\t{\n\t\titem.setDepth(incrementDepth(item.getPartialTunnelId(), item.getDepth()));\n\t}\n\n\tprivate short incrementDepth(int id, short depth)\n\t{\n\t\tvar randomDepthSkipShift = depth == 1 && (((bias ^ id) & 0x7) == 6);\n\n\t\tif (!randomDepthSkipShift)\n\t\t{\n\t\t\tdepth++;\n\t\t}\n\t\treturn depth;\n\t}\n\n\tpublic int getBias()\n\t{\n\t\treturn bias;\n\t}\n\n\t/**\n\t * Gets the forwarding probability of a tunnel request.\n\t * <p></p>\n\t * A particular care is taken to not flood the network:\n\t * <ul>\n\t *     <li>if the number of tunnel requests to forward per seconds is below {@link #MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND}, keep the traffic</li>\n\t *     <li>if the limit is approached, start dropping with long tunnels first</li>\n\t * </ul>\n\t * Variables involved:\n\t * <ul>\n\t *     <li>distanceToMaximum: in [0,inf] is the proportion of the current up TR speed with respect to the maximum allowed speed. This is estimated\n\t *     as an average between the average number of TR over the 60 last seconds and the current TR up speed</li>\n\t *     <li>correctedDistance: in [0,inf] is a squeezed version of distance: small values become very small and large values become very large</li>\n\t *     <li>{@link #DEPTH_PEER_PROBABILITY}: basic probability of forwarding when the speed limit is reached</li>\n\t *     <li>forwardProbability: final probability of forwarding the packet, per peer</li>\n\t * </ul>\n\t * When the number of peers increases, the speed limit is reached faster, but the behavior per peer is the same.\n\t *\n\t * @param item  a {@link TurtleTunnelRequestItem}, not null\n\t * @param tunnelRequestsUpload   the bandwidth of tunnel requests (up) in bytes per seconds\n\t * @param tunnelRequestsDownload  the bandwidth of tunnel requests (down) in bytes per seconds\n\t * @param numberOfPeers  the number of connected peers\n\t * @return a probability value between 0.0 and 1.0, both inclusive\n\t */\n\tpublic double getForwardingProbability(TurtleTunnelRequestItem item, double tunnelRequestsUpload, double tunnelRequestsDownload, int numberOfPeers)\n\t{\n\t\tvar distanceToMaximum = Math.min(100.0, tunnelRequestsUpload / (TUNNEL_REQUEST_PACKET_SIZE * MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND));\n\t\tvar correctedDistance = Math.pow(distanceToMaximum, DISTANCE_SQUEEZING_POWER);\n\t\tvar forwardProbability = Math.pow(DEPTH_PEER_PROBABILITY[Math.min(DEPTH_PEER_PROBABILITY.length - 1, item.getDepth())], correctedDistance);\n\n\t\tif (forwardProbability * numberOfPeers < 1.0 && numberOfPeers > 0)\n\t\t{\n\t\t\tforwardProbability = 1.0 / numberOfPeers;\n\n\t\t\tif (tunnelRequestsDownload / TUNNEL_REQUEST_PACKET_SIZE > MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND)\n\t\t\t{\n\t\t\t\tforwardProbability *= MAX_TUNNEL_REQUEST_FORWARD_PER_SECOND * TUNNEL_REQUEST_PACKET_SIZE / tunnelRequestsDownload;\n\t\t\t}\n\t\t}\n\t\treturn forwardProbability;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/TunnelRequest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.model.location.Location;\n\nimport java.time.Instant;\nimport java.util.HashSet;\nimport java.util.Set;\n\n/**\n * Keeps track of tunnel requests.\n */\nclass TunnelRequest\n{\n\tprivate final Location source;\n\tprivate final Instant lastUsed;\n\tprivate final int depth;\n\tprivate final Set<Integer> responses;\n\n\t/**\n\t * Creates a tunnel request.\n\t *\n\t * @param source where the request came from\n\t * @param depth  depth of the request, used to optimize tunnel length\n\t */\n\tpublic TunnelRequest(Location source, int depth)\n\t{\n\t\tthis.source = source;\n\t\tlastUsed = Instant.now();\n\t\tthis.depth = depth;\n\t\tresponses = new HashSet<>();\n\t}\n\n\tpublic Location getSource()\n\t{\n\t\treturn source;\n\t}\n\n\tpublic Instant getLastUsed()\n\t{\n\t\treturn lastUsed;\n\t}\n\n\tpublic int getDepth()\n\t{\n\t\treturn depth;\n\t}\n\n\tpublic Set<Integer> getResponses()\n\t{\n\t\treturn responses;\n\t}\n\n\tpublic boolean hasResponseAlready(int id)\n\t{\n\t\treturn responses.contains(id);\n\t}\n\n\tpublic void addResponse(int id)\n\t{\n\t\tresponses.add(id);\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleRouter.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\nimport io.xeres.common.id.Sha1Sum;\n\n/**\n * Represents a Turtle Router. It is given to Turtle Clients in the initialization method to enable its functions to be called anytime.\n * <p>\n * Only encrypted hashes are supported.\n */\npublic interface TurtleRouter\n{\n\t/**\n\t * Starts monitoring tunnels for a given hash.\n\t * <p>\n\t * Should be called before downloading a file so that the turtle router can provide the tunnels for it.\n\t *\n\t * @param hash              the encrypted hash to monitor tunnels for\n\t * @param client            the {@link TurtleRsClient}\n\t * @param allowMultiTunnels true to allow multiple tunnels to be created (aggressive mode), otherwise only use one tunnel\n\t */\n\tvoid startMonitoringTunnels(Sha1Sum hash, TurtleRsClient client, boolean allowMultiTunnels);\n\n\t/**\n\t * Stops monitoring tunnels for a given hash.\n\t * <p>\n\t * Should be called after a download is finished (successfully or not) so that the tunnels can be cleaned up.\n\t *\n\t * @param hash the encrypted hash to stops monitoring tunnels for\n\t */\n\tvoid stopMonitoringTunnels(Sha1Sum hash);\n\n\t/**\n\t * Forces to re-digg a tunnel.\n\t *\n\t * @param hash the encrypted hash to re-digg a tunnel for\n\t */\n\tvoid forceReDiggTunnel(Sha1Sum hash);\n\n\t/**\n\t * Sends data using Turtle.\n\t *\n\t * @param virtualPeer the virtual peer to send data to\n\t * @param item        the data represented by any subclass of {@link TurtleGenericTunnelItem}\n\t */\n\tvoid sendTurtleData(Location virtualPeer, TurtleGenericTunnelItem item);\n\n\t/**\n\t * Checks if a location is a virtual turtle peer.\n\t *\n\t * @param location the location\n\t * @return true if it's a virtual turtle peer\n\t */\n\tboolean isVirtualPeer(Location location);\n\n\t/**\n\t * Performs a tunnel search.\n\t *\n\t * @param search the search string\n\t * @param client a {@link TurtleRsClient}\n\t * @return the search id\n\t */\n\tint turtleSearch(String search, TurtleRsClient client);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleRsClient.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.xrs.service.RsServiceSlave;\nimport io.xeres.app.xrs.service.filetransfer.FileTransferRsService;\nimport io.xeres.app.xrs.service.turtle.item.TunnelDirection;\nimport io.xeres.app.xrs.service.turtle.item.TurtleGenericTunnelItem;\nimport io.xeres.app.xrs.service.turtle.item.TurtleSearchResultItem;\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.util.List;\n\n/**\n * Represents a turtle clients. For example the {@link FileTransferRsService file transfer service} is a turtle client and\n * will receive events from the {@link TurtleRsService turtle router}.\n */\npublic interface TurtleRsClient extends RsServiceSlave\n{\n\t/**\n\t * Called to initialize the turtle client.\n\t *\n\t * @param turtleRouter  the {@link TurtleRouter}. Keep it somewhere so that you can call its methods.\n\t */\n\tvoid initializeTurtle(TurtleRouter turtleRouter);\n\n\t/**\n\t * Called to ask if this hash can be handled.\n\t * <p>\n\t * It usually boils down to searching it in some database or list.\n\t *\n\t * @param sender  the {@link PeerConnection} where it comes from\n\t * @param hash  the encrypted hash\n\t * @return true if it can be handled\n\t */\n\tboolean handleTunnelRequest(PeerConnection sender, Sha1Sum hash);\n\n\t/**\n\t * Called when receiving data from a tunnel.\n\t *\n\t * @param item            a {@link TurtleGenericTunnelItem} subclass\n\t * @param hash            the encrypted hash from which the data is related to\n\t * @param virtualLocation the virtual location\n\t * @param tunnelDirection if data is from a {@link TunnelDirection#SERVER} or a {@link TunnelDirection#CLIENT}\n\t */\n\tvoid receiveTurtleData(TurtleGenericTunnelItem item, Sha1Sum hash, Location virtualLocation, TunnelDirection tunnelDirection);\n\n\t/**\n\t * Called to ask to search for something.\n\t *\n\t * @param query  the search query\n\t * @param maxHits  the maximum number of hits to send back\n\t * @return the search results\n\t */\n\tList<byte[]> receiveSearchRequest(byte[] query, int maxHits); // XXX: return a list of results (TurtleFileInfoV2.. actually it's generic stuff so service dependent)\n\n\tvoid receiveSearchRequestString(PeerConnection sender, String keywords); // XXX: experimental for now...\n\n\t/**\n\t * Called when receiving search results.\n\t *\n\t * @param requestId  the request id the search result belongs to\n\t * @param item  a {@link TurtleSearchResultItem} subclass containing the results\n\t */\n\tvoid receiveSearchResult(int requestId, TurtleSearchResultItem item);\n\n\t// XXX: document that only encrypted hashes are supported\n\n\t/**\n\t * Called when a virtual peer related to a hash is added.\n\t *\n\t * @param hash  the encrypted hash\n\t * @param virtualLocation  the virtual location to add\n\t * @param direction  the direction of the tunnel, either {@link TunnelDirection#SERVER} or {@link TunnelDirection#CLIENT}\n\t */\n\tvoid addVirtualPeer(Sha1Sum hash, Location virtualLocation, TunnelDirection direction);\n\n\t/**\n\t * Called when a virtual peer related to a hash is removed.\n\t *\n\t * @param hash  the encrypted hash\n\t * @param virtualLocation  the virtual location to remove\n\t */\n\tvoid removeVirtualPeer(Sha1Sum hash, Location virtualLocation);\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleRsService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.DatabaseSession;\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.file.FileService;\nimport io.xeres.app.util.expression.ExpressionMapper;\nimport io.xeres.app.util.expression.NameExpression;\nimport io.xeres.app.util.expression.StringExpression;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceMaster;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.turtle.item.*;\nimport io.xeres.common.file.FileType;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.common.util.SecureRandomUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ScheduledExecutorService;\n\nimport static io.xeres.common.protocol.xrs.RsServiceType.TURTLE_ROUTER;\n\n/**\n * Implementation of the {@link TurtleRouter}. Only supports encrypted hashes.\n */\n@Component\npublic class TurtleRsService extends RsService implements RsServiceMaster<TurtleRsClient>, TurtleRouter\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TurtleRsService.class);\n\n\t/**\n\t * Time between tunnel management runs.\n\t */\n\tprivate static final Duration TUNNEL_MANAGEMENT_DELAY = Duration.ofSeconds(2);\n\n\t/**\n\t * Maximum tunnel depth, that is the number of friends beyond you that are reachable.\n\t */\n\tpublic static final int MAX_TUNNEL_DEPTH = 6;\n\n\t/**\n\t * Time between checks of empty tunnels.\n\t */\n\tpublic static final Duration EMPTY_TUNNELS_DIGGING_TIME = Duration.ofSeconds(50);\n\n\t/**\n\t * Time between checks of normal tunnels.\n\t */\n\tprivate static final Duration REGULAR_TUNNELS_DIGGING_TIME = Duration.ofMinutes(5);\n\n\t/**\n\t * Time between tunnels cleanup.\n\t */\n\tprivate static final Duration TUNNEL_CLEANING_TIME = Duration.ofSeconds(10);\n\n\t/**\n\t * Time between tunnel speed estimation runs.\n\t */\n\tprivate static final Duration SPEED_ESTIMATE_TIME = Duration.ofSeconds(5);\n\n\t/**\n\t * Maximum number of search requests allowed in the cache.\n\t */\n\tprivate static final int MAX_SEARCH_REQUEST_IN_CACHE = 120;\n\n\t/**\n\t * Maximum number of search results forwarded by default.\n\t */\n\tprivate static final int MAX_SEARCH_HITS = 100;\n\n\tprivate static final int MAX_SEARCH_REQUEST_ACCEPTED_SERIAL_SIZE = 200;\n\n\tprivate static final int MAX_SEARCH_RESPONSE_SERIAL_SIZE = 10000;\n\n\t/**\n\t * Maximum lifetime of unused tunnels.\n\t */\n\tprivate static final Duration MAX_TUNNEL_IDLE_TIME = Duration.ofSeconds(60);\n\n\t/**\n\t * Lifetime of search requests in the cache.\n\t */\n\tprivate static final Duration SEARCH_REQUEST_LIFETIME = Duration.ofMinutes(10);\n\n\t/**\n\t * Lifetime of tunnel requests in the cache.\n\t */\n\tprivate static final Duration TUNNEL_REQUEST_LIFETIME = Duration.ofMinutes(10);\n\n\t/**\n\t * Lifetime of an ongoing search requests. Results coming after that time are dropped.\n\t */\n\tprivate static final Duration SEARCH_REQUEST_TIMEOUT = Duration.ofSeconds(20);\n\n\t/**\n\t * Lifetime of an ongoing tunnel requests. Results coming after that time are dropped.\n\t */\n\tprivate static final Duration TUNNEL_REQUEST_TIMEOUT = Duration.ofSeconds(20);\n\n\tprivate final TunnelProbability tunnelProbability = new TunnelProbability();\n\n\tprivate final Map<Integer, SearchRequest> searchRequestsOrigins = new ConcurrentHashMap<>();\n\n\tprivate final Map<Integer, TunnelRequest> tunnelRequestsOrigins = new ConcurrentHashMap<>();\n\n\tprivate final Map<Sha1Sum, HashInfo> incomingHashes = new ConcurrentHashMap<>();\n\n\tprivate final Map<Integer, Tunnel> localTunnels = new ConcurrentHashMap<>();\n\n\tprivate final Map<LocationIdentifier, Integer> virtualPeers = new ConcurrentHashMap<>();\n\n\tprivate final Set<Sha1Sum> hashesToRemove = ConcurrentHashMap.newKeySet();\n\n\tprivate final Map<Integer, TurtleRsClient> outgoingTunnelClients = new ConcurrentHashMap<>();\n\n\tprivate final List<TurtleRsClient> turtleClients = new ArrayList<>();\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\n\tprivate final LocationService locationService;\n\n\tprivate final DatabaseSessionManager databaseSessionManager;\n\n\tprivate final FileService fileService;\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate Location ownLocation;\n\n\tprivate Instant lastTunnelCleanup = Instant.EPOCH;\n\n\tprivate Instant lastSpeedEstimation = Instant.EPOCH;\n\n\tprivate TurtleStatistics turtleStatistics = new TurtleStatistics();\n\tprivate final TurtleStatistics turtleStatisticsBuffer = new TurtleStatistics();\n\n\tprotected TurtleRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, LocationService locationService, DatabaseSessionManager databaseSessionManager, FileService fileService)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.locationService = locationService;\n\t\tthis.databaseSessionManager = databaseSessionManager;\n\t\tthis.fileService = fileService;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn TURTLE_ROUTER;\n\t}\n\n\t@Override\n\tpublic void addRsSlave(TurtleRsClient client)\n\t{\n\t\tturtleClients.add(client);\n\t\tclient.initializeTurtle(this);\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\ttry (var ignored = new DatabaseSession(databaseSessionManager))\n\t\t{\n\t\t\townLocation = locationService.findOwnLocation().orElseThrow();\n\t\t}\n\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(this::manageAll,\n\t\t\t\tgetInitPriority().getMaxTime() + TUNNEL_MANAGEMENT_DELAY.toSeconds() / 2,\n\t\t\t\tTUNNEL_MANAGEMENT_DELAY.toSeconds());\n\t}\n\n\t@Override\n\tpublic void cleanup()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\t@Transactional\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tswitch (item)\n\t\t{\n\t\t\tcase TurtleGenericTunnelItem turtleGenericTunnelItem -> routeGenericTunnel(sender, turtleGenericTunnelItem);\n\t\t\tcase TurtleTunnelRequestItem turtleTunnelRequestItem -> handleTunnelRequest(sender, turtleTunnelRequestItem);\n\t\t\tcase TurtleTunnelResultItem turtleTunnelResultItem -> handleTunnelResult(sender, turtleTunnelResultItem);\n\t\t\tcase TurtleSearchRequestItem turtleSearchRequestItem -> handleSearchRequest(sender, turtleSearchRequestItem);\n\t\t\tcase TurtleSearchResultItem turtleSearchResultItem -> handleSearchResult(sender, turtleSearchResultItem);\n\t\t\tdefault -> log.debug(\"Unknown item {}\", item);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void forceReDiggTunnel(Sha1Sum hash)\n\t{\n\t\tif (!incomingHashes.containsKey(hash))\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tdiggTunnel(hash);\n\t}\n\n\t@Override\n\tpublic void sendTurtleData(Location virtualPeer, TurtleGenericTunnelItem item)\n\t{\n\t\tvar tunnelId = virtualPeers.get(virtualPeer.getLocationIdentifier());\n\t\tif (tunnelId == null)\n\t\t{\n\t\t\tlog.warn(\"Couldn't find tunnel for virtual peer id {} when sending data\", virtualPeer.getLocationIdentifier());\n\t\t\treturn;\n\t\t}\n\n\t\tvar tunnel = localTunnels.get(tunnelId);\n\t\tif (tunnel == null)\n\t\t{\n\t\t\tlog.warn(\"Client asked to send a packet through a tunnel that has been deleted.\");\n\t\t\treturn;\n\t\t}\n\n\t\titem.setTunnelId(tunnelId);\n\n\t\tif (item.shouldStampTunnel())\n\t\t{\n\t\t\ttunnel.stamp();\n\t\t}\n\n\t\tif (tunnel.getSource().equals(ownLocation))\n\t\t{\n\t\t\titem.setDirection(TunnelDirection.SERVER);\n\t\t\tlog.trace(\"Sending turtle item {} to {} (server)\", item, tunnel.getDestination());\n\t\t\tvar itemFuture = peerConnectionManager.writeItem(tunnel.getDestination(), item, this);\n\t\t\tturtleStatisticsBuffer.addToDataDownload(itemFuture.getSize());\n\t\t\ttunnel.addTransferredBytes(itemFuture.getSize());\n\t\t}\n\t\telse if (tunnel.getDestination().equals(ownLocation))\n\t\t{\n\t\t\titem.setDirection(TunnelDirection.CLIENT);\n\t\t\tlog.trace(\"Sending turtle item {} to {} (client)\", item, tunnel.getSource());\n\t\t\tvar itemFuture = peerConnectionManager.writeItem(tunnel.getSource(), item, this);\n\t\t\tturtleStatisticsBuffer.addToDataUpload(itemFuture.getSize());\n\t\t\ttunnel.addTransferredBytes(itemFuture.getSize());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.error(\"Asked to send a packet into a tunnel that is not registered, dropping packet\");\n\t\t}\n\t}\n\n\t@Override\n\tpublic void startMonitoringTunnels(Sha1Sum hash, TurtleRsClient client, boolean allowMultiTunnels)\n\t{\n\t\tlog.debug(\"Start monitoring tunnels for (encrypted) hash {}\", hash);\n\t\thashesToRemove.remove(hash); // if the file hash was scheduled for removal, cancel it\n\n\t\tincomingHashes.putIfAbsent(hash, new HashInfo(allowMultiTunnels, client));\n\t}\n\n\t@Override\n\tpublic void stopMonitoringTunnels(Sha1Sum hash)\n\t{\n\t\tlog.debug(\"Stop monitoring tunnels for (encrypted) hash {}\", hash);\n\t\thashesToRemove.add(hash);\n\t}\n\n\tprivate void routeGenericTunnel(PeerConnection sender, TurtleGenericTunnelItem item)\n\t{\n\t\tlog.trace(\"Routing generic tunnel {} from {}\", item, sender);\n\t\tvar tunnel = localTunnels.get(item.getTunnelId());\n\t\tif (tunnel == null)\n\t\t{\n\t\t\tlog.error(\"Got item {} with unknown tunnel id {} from {}, dropping\", item, item.getTunnelId(), sender);\n\t\t\treturn;\n\t\t}\n\n\t\tif (item.shouldStampTunnel())\n\t\t{\n\t\t\ttunnel.stamp();\n\t\t}\n\n\t\tif (sender.getLocation().equals(tunnel.getDestination()))\n\t\t{\n\t\t\titem.setDirection(TunnelDirection.CLIENT);\n\t\t}\n\t\telse if (sender.getLocation().equals(tunnel.getSource()))\n\t\t{\n\t\t\titem.setDirection(TunnelDirection.SERVER);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.error(\"Generic tunnel mismatch source/destination id\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (sender.getLocation().equals(tunnel.getDestination()) && !tunnel.getSource().equals(ownLocation))\n\t\t{\n\t\t\tlog.trace(\"Forwarding generic item {} to {}\", item, tunnel.getSource());\n\t\t\tvar itemFuture = peerConnectionManager.writeItem(tunnel.getSource(), item.clone(), this);\n\t\t\tturtleStatisticsBuffer.addToForwardTotal(itemFuture.getSize());\n\t\t\ttunnel.addTransferredBytes(itemFuture.getSize());\n\t\t\treturn;\n\t\t}\n\n\t\tif (sender.getLocation().equals(tunnel.getSource()) && !tunnel.getDestination().equals(ownLocation))\n\t\t{\n\t\t\tlog.trace(\"Forwarding generic item {} to {}\", item, tunnel.getDestination());\n\t\t\tvar itemFuture = peerConnectionManager.writeItem(tunnel.getDestination(), item.clone(), this);\n\t\t\tturtleStatisticsBuffer.addToForwardTotal(itemFuture.getSize());\n\t\t\ttunnel.addTransferredBytes(itemFuture.getSize());\n\t\t\treturn;\n\t\t}\n\n\t\t// Item is for us\n\t\tturtleStatisticsBuffer.addToDataDownload(item.getItemSize());\n\t\thandleReceiveGenericTunnel(item, tunnel);\n\t}\n\n\t@Override\n\tpublic boolean isVirtualPeer(Location location)\n\t{\n\t\treturn virtualPeers.containsKey(location.getLocationIdentifier());\n\t}\n\n\tprivate void handleReceiveGenericTunnel(TurtleGenericTunnelItem item, Tunnel tunnel)\n\t{\n\t\tTurtleRsClient client = null;\n\n\t\tif (tunnel.getSource().equals(ownLocation))\n\t\t{\n\t\t\tvar hashInfo = incomingHashes.get(tunnel.getHash());\n\t\t\tif (hashInfo == null)\n\t\t\t{\n\t\t\t\tlog.warn(\"Hash {} for client side tunnel endpoint {} has been removed (late response?), dropping\", tunnel.getHash(), item.getTunnelId());\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tclient = hashInfo.getClient();\n\t\t}\n\t\telse if (tunnel.getDestination().equals(ownLocation))\n\t\t{\n\t\t\tclient = outgoingTunnelClients.get(item.getTunnelId());\n\t\t\tif (client == null)\n\t\t\t{\n\t\t\t\tlog.warn(\"Hash {} for server side tunnel endpoint {} has been removed (late response?), dropping\", tunnel.getHash(), item.getTunnelId());\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tassert client != null;\n\t\tclient.receiveTurtleData(item, tunnel.getHash(), tunnel.getVirtualLocation(), item.getDirection());\n\t}\n\n\tprivate void handleTunnelRequest(PeerConnection sender, TurtleTunnelRequestItem item)\n\t{\n\t\tlog.trace(\"Received tunnel request from peer {}: {}\", sender, item);\n\n\t\tturtleStatisticsBuffer.addToTunnelRequestsDownload(item.getItemSize());\n\n\t\t// RS sometimes sends null (0000...) hashes\n\t\tif (item.getHash() == null)\n\t\t{\n\t\t\tlog.debug(\"Null hash in tunnel request, dropping...\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (isBanned(item.getHash()))\n\t\t{\n\t\t\tlog.debug(\"Rejecting banned file hash {}\", item.getHash());\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelRequestsOrigins.putIfAbsent(item.getRequestId(), new TunnelRequest(sender.getLocation(), item.getDepth())) != null)\n\t\t{\n\t\t\t// This can happen when the same tunnel request is relayed by different peers.\n\t\t\t// Simply drop it.\n\t\t\tlog.debug(\"Requests {} already exists\", item.getRequestId());\n\t\t\treturn;\n\t\t}\n\n\t\tOptional<TurtleRsClient> clientWithSearchResult = Optional.empty();\n\n\t\t// If it's not from us, perform a local search.\n\t\tif (!sender.getLocation().equals(ownLocation))\n\t\t{\n\t\t\tclientWithSearchResult = turtleClients.stream()\n\t\t\t\t\t.filter(turtleRsClient -> turtleRsClient.handleTunnelRequest(sender, item.getHash()))\n\t\t\t\t\t.findFirst();\n\t\t}\n\n\t\t// If a client found something, send the search result back.\n\t\tif (clientWithSearchResult.isPresent())\n\t\t{\n\t\t\tvar tunnelId = item.getPartialTunnelId() ^ generatePersonalFilePrint(item.getHash(), tunnelProbability.getBias(), false);\n\t\t\tlog.debug(\"Honoring tunnel request from peer {}: {}. generated tunnel id: {}\", sender, item, tunnelId);\n\t\t\tvar resultItem = new TurtleTunnelResultItem(tunnelId, item.getRequestId());\n\n\t\t\tvar tunnel = new Tunnel(tunnelId, sender.getLocation(), ownLocation, item.getHash());\n\t\t\tlocalTunnels.put(tunnelId, tunnel);\n\t\t\tvirtualPeers.put(tunnel.getVirtualLocation().getLocationIdentifier(), tunnelId);\n\n\t\t\toutgoingTunnelClients.put(tunnelId, clientWithSearchResult.get());\n\n\t\t\tpeerConnectionManager.writeItem(sender, resultItem, this);\n\n\t\t\tclientWithSearchResult.get().addVirtualPeer(item.getHash(), tunnel.getVirtualLocation(), TunnelDirection.CLIENT);\n\t\t\treturn;\n\t\t}\n\n\t\t// Perturb the partial tunnel id so that:\n\t\t// - the tunnel id is unique for a given route\n\t\t// - better balance of bandwidth for a given transfer\n\t\t// - avoids the waste of items that get lost when re-routing a tunnel\n\t\titem.setPartialTunnelId(generatePersonalFilePrint(item.getHash(), item.getPartialTunnelId() ^ tunnelProbability.getBias(), true));\n\n\t\tif (tunnelProbability.isForwardable(item))\n\t\t{\n\t\t\tvar probability = tunnelProbability.getForwardingProbability(\n\t\t\t\t\titem,\n\t\t\t\t\tturtleStatistics.getTunnelRequestsUpload(),\n\t\t\t\t\tturtleStatistics.getTunnelRequestsDownload(),\n\t\t\t\t\tpeerConnectionManager.getNumberOfPeers());// XXX: there's a difference with RS here, it's the number of peers USING the turtle service. do we care?\n\n\t\t\tpeerConnectionManager.doForAllPeersExceptSender(peerConnection -> {\n\t\t\t\t\t\tvar itemToSend = item.clone();\n\t\t\t\t\t\ttunnelProbability.incrementDepth(itemToSend);\n\t\t\t\t\t\tif (SecureRandomUtils.nextDouble() <= probability)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this);\n\t\t\t\t\t\t\tturtleStatisticsBuffer.addToTunnelRequestsUpload(itemFuture.getSize());\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tsender,\n\t\t\t\t\tthis);\n\t\t}\n\t}\n\n\tprivate void handleTunnelResult(PeerConnection sender, TurtleTunnelResultItem item)\n\t{\n\t\tlog.debug(\"Got tunnel result from {}: {}\", sender, item);\n\t\tvar tunnelRequest = tunnelRequestsOrigins.get(item.getRequestId());\n\t\tif (tunnelRequest == null)\n\t\t{\n\t\t\tlog.warn(\"Tunnel result has no peer direction.\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelRequest.hasResponseAlready(item.getTunnelId()))\n\t\t{\n\t\t\tlog.error(\"Received a tunnel response twice. This should not happen.\");\n\t\t\treturn;\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttunnelRequest.addResponse(item.getTunnelId());\n\t\t}\n\n\t\t// Transitive tunnel\n\t\tvar tunnel = localTunnels.computeIfAbsent(item.getTunnelId(), tunnelId -> new Tunnel(tunnelId, tunnelRequest.getSource(), sender.getLocation(), null));\n\n\t\tif (Duration.between(tunnelRequest.getLastUsed(), Instant.now()).compareTo(TUNNEL_REQUEST_TIMEOUT) > 0)\n\t\t{\n\t\t\tlog.warn(\"Tunnel request is known but the tunnel result arrived too late, dropping\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if it's for ourselves\n\t\tif (tunnelRequest.getSource().equals(ownLocation))\n\t\t{\n\t\t\tvar hashInfo = findHashInfoByRequest(item.getRequestId());\n\t\t\thashInfo.ifPresent(hInfo -> {\n\t\t\t\thInfo.getValue().addTunnel(item.getTunnelId());\n\n\t\t\t\t// Local tunnel\n\t\t\t\ttunnel.setHash(hInfo.getKey());\n\t\t\t\tvirtualPeers.put(tunnel.getVirtualLocation().getLocationIdentifier(), item.getTunnelId());\n\t\t\t\thInfo.getValue().getClient().addVirtualPeer(hInfo.getKey(), tunnel.getVirtualLocation(), TunnelDirection.SERVER);\n\t\t\t});\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Forward the result back to its source\n\t\t\tpeerConnectionManager.writeItem(tunnelRequest.getSource(), new TurtleTunnelResultItem(item.getTunnelId(), item.getRequestId()), this);\n\t\t}\n\t}\n\n\tprivate Optional<Map.Entry<Sha1Sum, HashInfo>> findHashInfoByRequest(int requestId)\n\t{\n\t\treturn incomingHashes.entrySet().stream()\n\t\t\t\t.filter(entry -> entry.getValue().getLastRequest() == requestId)\n\t\t\t\t.findFirst();\n\t}\n\n\tint generatePersonalFilePrint(Sha1Sum hash, int bias, boolean symmetrical)\n\t{\n\t\tvar buf = hash.toString() + ownLocation.getLocationIdentifier().toString();\n\t\tint result = bias;\n\t\tvar decal = 0;\n\n\t\tfor (var i = 0; i < buf.length(); i++)\n\t\t{\n\t\t\tresult += (int) (7 * buf.charAt(i) + Integer.toUnsignedLong(decal));\n\n\t\t\tif (symmetrical)\n\t\t\t{\n\t\t\t\tdecal = (int) (Integer.toUnsignedLong(decal) * 44497 + 15641 + (Integer.toUnsignedLong(result) % 86243));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tdecal = (int) (Integer.toUnsignedLong(decal) * 86243 + 15649 + (Integer.toUnsignedLong(result) % 44497));\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate void handleSearchRequest(PeerConnection sender, TurtleSearchRequestItem item)\n\t{\n\t\tlog.debug(\"Received search request from peer {}: {}\", sender, item);\n\n\t\tvar itemSize = item.getItemSize();\n\n\t\tturtleStatisticsBuffer.addToSearchRequestsDownload(itemSize);\n\n\t\tif (itemSize > MAX_SEARCH_REQUEST_ACCEPTED_SERIAL_SIZE)\n\t\t{\n\t\t\tlog.warn(\"Got an arbitrary large size from {} of size {} and depth {}. Dropping\", sender, itemSize, item.getDepth());\n\t\t\treturn;\n\t\t}\n\n\t\tif (searchRequestsOrigins.size() > MAX_SEARCH_REQUEST_IN_CACHE) // XXX: no expiration for those??\n\t\t{\n\t\t\tlog.debug(\"Request cache is full. Check if a peer is flooding.\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar searchResults = performLocalSearch(item, MAX_SEARCH_HITS);\n\t\tsearchResults.forEach(turtleSearchResultItem -> {\n\t\t\tturtleSearchResultItem.setRequestId(item.getRequestId());\n\t\t\tpeerConnectionManager.writeItem(sender, turtleSearchResultItem, this);\n\t\t});\n\n\t\tvar searchRequest = new SearchRequest(sender.getLocation(),\n\t\t\t\titem.getDepth(),\n\t\t\t\titem.getKeywords(),\n\t\t\t\tsearchResults.size(),\n\t\t\t\tMAX_SEARCH_HITS);\n\n\t\tif (searchRequestsOrigins.putIfAbsent(item.getRequestId(), searchRequest) != null)\n\t\t{\n\t\t\tlog.debug(\"Request {} already in cache\", item.getRequestId());\n\t\t\treturn;\n\t\t}\n\n\t\t// XXX: experimental\n\t\tturtleClients.forEach(turtleRsClient -> turtleRsClient.receiveSearchRequestString(sender, item.getKeywords()));\n\n\t\t// Do not search further if enough has been sent back already.\n\t\tif (searchRequest.isFull())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnelProbability.isForwardable(item))\n\t\t{\n\t\t\tpeerConnectionManager.doForAllPeersExceptSender(peerConnection -> {\n\t\t\t\t\t\tvar itemToSend = item.clone();\n\t\t\t\t\t\ttunnelProbability.incrementDepth(itemToSend);\n\t\t\t\t\t\tvar itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this);\n\t\t\t\t\t\tturtleStatisticsBuffer.addToSearchRequestsUpload(itemFuture.getSize());\n\t\t\t\t\t},\n\t\t\t\t\tsender,\n\t\t\t\t\tthis);\n\t\t}\n\t}\n\n\t@Override\n\tpublic int turtleSearch(String search, TurtleRsClient client) // XXX: put a size limit there in the search string length...\n\t{\n\t\tvar id = SecureRandomUtils.nextInt();\n\n\t\tTurtleFileSearchRequestItem item;\n\n\t\t// \"foobar\" -> exact search\n\t\tif (search.startsWith(\"\\\"\") && search.endsWith(\"\\\"\"))\n\t\t{\n\t\t\tsearch = search.substring(1, search.length() - 1);\n\t\t\titem = new TurtleStringSearchRequestItem(search);\n\t\t}\n\t\telse if (search.contains(\" \")) // The Stuff -> search all terms, in this case \"Stuff, The\" is a match\n\t\t{\n\t\t\tvar nameExpression = new NameExpression(StringExpression.Operator.CONTAINS_ALL, search, false);\n\t\t\titem = ExpressionMapper.toItem(List.of(nameExpression));\n\t\t}\n\t\telse // One word is just a string search\n\t\t{\n\t\t\titem = new TurtleStringSearchRequestItem(search);\n\t\t}\n\n\t\titem.setRequestId(id);\n\n\t\tvar request = new SearchRequest(client, ownLocation, 0, search, 0, MAX_SEARCH_HITS);\n\t\tsearchRequestsOrigins.put(id, request);\n\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> {\n\t\t\tvar itemToSend = item.clone();\n\t\t\tvar itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this);\n\t\t\tturtleStatisticsBuffer.addToSearchRequestsUpload(itemFuture.getSize());\n\t\t}, this);\n\n\t\treturn id;\n\t}\n\n\tprivate List<TurtleSearchResultItem> performLocalSearch(TurtleSearchRequestItem item, int maxHits)\n\t{\n\t\tList<TurtleSearchResultItem> results = new ArrayList<>();\n\n\t\tif (item instanceof TurtleFileSearchRequestItem fileSearchItem)\n\t\t{\n\t\t\tlog.debug(\"Received file search: {}, subclass: {}\", fileSearchItem.getKeywords(), fileSearchItem.getClass().getSimpleName());\n\t\t\treturn mapResults(searchFiles(fileSearchItem).stream()\n\t\t\t\t\t.filter(this::isSearchable)\n\t\t\t\t\t.limit(maxHits)\n\t\t\t\t\t.sorted(Comparator.comparing(File::getModified).reversed()) // Get the most recents first\n\t\t\t\t\t.map(file -> new TurtleFileInfo(file.getName(), file.getHash(), file.getSize()))\n\t\t\t\t\t.toList());\n\t\t}\n\t\telse if (item instanceof TurtleGenericSearchRequestItem genericSearchRequestItem)\n\t\t{\n\t\t\tlog.debug(\"Received generic search: {}\", genericSearchRequestItem.getKeywords());\n\t\t\t// XXX: generic search\n\t\t}\n\t\treturn results;\n\t}\n\n\tprivate List<File> searchFiles(TurtleFileSearchRequestItem turtleFileSearchRequestItem)\n\t{\n\t\treturn switch (turtleFileSearchRequestItem)\n\t\t{\n\t\t\tcase TurtleStringSearchRequestItem item -> fileService.searchFiles(item.getKeywords());\n\t\t\tcase TurtleRegExpSearchRequestItem item -> fileService.searchFiles(item.getExpressions());\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + turtleFileSearchRequestItem);\n\t\t};\n\t}\n\n\tprivate boolean isSearchable(File file)\n\t{\n\t\tif (file.getType() == FileType.DIRECTORY)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\tvar share = fileService.findShareForFile(file).orElseThrow(() -> new IllegalStateException(\"File \" + file + \" is not in any share. Shouldn't happen.\"));\n\t\treturn share.isSearchable();\n\t}\n\n\tprivate static List<TurtleSearchResultItem> mapResults(List<TurtleFileInfo> fileInfos)\n\t{\n\t\tList<TurtleSearchResultItem> results = new ArrayList<>();\n\t\tTurtleFileSearchResultItem item = null;\n\t\tvar fileInfoSize = 0;\n\n\t\tfor (TurtleFileInfo fileInfo : fileInfos)\n\t\t{\n\t\t\tif (item == null)\n\t\t\t{\n\t\t\t\titem = new TurtleFileSearchResultItem();\n\t\t\t\tresults.add(item);\n\t\t\t\tfileInfoSize = 0;\n\t\t\t}\n\n\t\t\titem.addFileInfo(fileInfo);\n\t\t\tfileInfoSize += fileInfo.getSize();\n\n\t\t\tif (fileInfoSize > MAX_SEARCH_RESPONSE_SERIAL_SIZE)\n\t\t\t{\n\t\t\t\titem = null;\n\t\t\t}\n\t\t}\n\t\treturn results;\n\t}\n\n\tprivate void handleSearchResult(PeerConnection sender, TurtleSearchResultItem item)\n\t{\n\t\tlog.debug(\"Received search result from peer {}: {}\", sender, item);\n\n\t\t//noinspection StatementWithEmptyBody\n\t\tif (item instanceof TurtleFileSearchResultItem _)\n\t\t{\n\t\t\t// XXX: remove all the isBanned() files from the result set\n\t\t}\n\n\t\tvar searchRequest = searchRequestsOrigins.get(item.getRequestId());\n\t\tif (searchRequest == null)\n\t\t{\n\t\t\tlog.warn(\"Search result for request {} doesn't exist in the cache\", item);\n\t\t\treturn;\n\t\t}\n\n\t\tif (Duration.between(searchRequest.getLastUsed(), Instant.now()).compareTo(SEARCH_REQUEST_TIMEOUT) > 0)\n\t\t{\n\t\t\tlog.debug(\"Search result arrived too late, dropping...\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (searchRequest.getSource().equals(ownLocation))\n\t\t{\n\t\t\tlog.debug(\"Search result is for us, forwarding to right service...\");\n\t\t\tsearchRequest.addResultCount(item.getCount());\n\t\t\tsearchRequest.getClient().receiveSearchResult(item.getRequestId(), item);\n\t\t\treturn;\n\t\t}\n\n\t\tif (searchRequest.isFull())\n\t\t{\n\t\t\tlog.warn(\"Exceeded turtle search result to forward. Request {} already forwarded: {}, max allowed: {}, dropping item with {} elements...\",\n\t\t\t\t\titem.getRequestId(),\n\t\t\t\t\tsearchRequest.getResultCount(),\n\t\t\t\t\tsearchRequest.getHitLimit(),\n\t\t\t\t\titem.getCount());\n\t\t\treturn;\n\t\t}\n\n\t\t// Update the count and make sure we don't exceed the limit before forwarding\n\t\tsearchRequest.addResultCount(item.getCount());\n\t\tif (searchRequest.isFull())\n\t\t{\n\t\t\titem.trim(searchRequest.getHitLimit());\n\t\t}\n\n\t\t// Forward the item to origin\n\t\tpeerConnectionManager.writeItem(searchRequest.getSource(), item.clone(), this);\n\t}\n\n\tprivate static boolean isBanned(Sha1Sum hash)\n\t{\n\t\treturn false; // TODO: implement\n\t}\n\n\tprivate void manageAll()\n\t{\n\t\tmanageTunnels();\n\t\tcomputeTrafficInformation();\n\t\tcleanTunnelsIfNeeded();\n\t\testimateSpeedIfNeeded();\n\t}\n\n\tprivate void manageTunnels()\n\t{\n\t\tvar now = Instant.now();\n\n\t\tincomingHashes.entrySet().stream()\n\t\t\t\t.filter(entry -> {\n\t\t\t\t\tvar hashInfo = entry.getValue();\n\t\t\t\t\tvar totalSpeed = hashInfo.getTunnels().stream()\n\t\t\t\t\t\t\t.mapToDouble(tunnelId -> localTunnels.get(tunnelId).getSpeedBps())\n\t\t\t\t\t\t\t.sum();\n\n\t\t\t\t\tvar tunnelKeepingFactor = (Math.max(1.0, totalSpeed / (50 * 1024)) - 1.0) + 1.0;\n\n\t\t\t\t\treturn ((!hashInfo.hasTunnels() && Duration.between(hashInfo.getLastDiggTime(), now).compareTo(EMPTY_TUNNELS_DIGGING_TIME) > 0) ||\n\t\t\t\t\t\t\t(hashInfo.isAggressiveMode() && Duration.between(hashInfo.getLastDiggTime(), now).compareTo(Duration.ofSeconds((long) (REGULAR_TUNNELS_DIGGING_TIME.toSeconds() * tunnelKeepingFactor))) > 0));\n\t\t\t\t})\n\t\t\t\t.sorted(Comparator.comparing(entry -> entry.getValue().getLastDiggTime()))\n\t\t\t\t.map(Map.Entry::getKey)\n\t\t\t\t.findFirst() // Digg at most 1 tunnel each 2 seconds\n\t\t\t\t.ifPresent(this::diggTunnel);\n\t}\n\n\tprivate void computeTrafficInformation()\n\t{\n\t\tturtleStatistics = turtleStatistics.multiply(0.9f).add(turtleStatisticsBuffer.multiply(0.1f / TUNNEL_MANAGEMENT_DELAY.toSeconds()));\n\t\tturtleStatisticsBuffer.reset();\n\t}\n\n\tprivate void diggTunnel(Sha1Sum hash)\n\t{\n\t\tvar requestId = SecureRandomUtils.nextInt();\n\t\tlog.debug(\"Digging tunnel for hash {}, requestId: {}\", hash, requestId);\n\n\t\tvar hashInfo = incomingHashes.get(hash);\n\n\t\thashInfo.setLastRequest(requestId);\n\t\thashInfo.setLastDiggTime(Instant.now());\n\n\t\tvar item = new TurtleTunnelRequestItem(hash, requestId, generatePersonalFilePrint(hash, tunnelProbability.getBias(), true));\n\n\t\ttunnelRequestsOrigins.put(item.getRequestId(), new TunnelRequest(ownLocation, item.getDepth()));\n\n\t\tpeerConnectionManager.doForAllPeers(peerConnection -> {\n\t\t\t\t\tvar itemToSend = item.clone();\n\t\t\t\t\tvar itemFuture = peerConnectionManager.writeItem(peerConnection, itemToSend, this);\n\t\t\t\t\tturtleStatisticsBuffer.addToTunnelRequestsUpload(itemFuture.getSize());\n\t\t\t\t},\n\t\t\t\tthis);\n\t}\n\n\tprivate void cleanTunnelsIfNeeded()\n\t{\n\t\tvar now = Instant.now();\n\t\tif (Duration.between(lastTunnelCleanup, now).compareTo(TUNNEL_CLEANING_TIME) <= 0)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tlastTunnelCleanup = now;\n\n\t\tvar virtualPeersToRemove = new HashMap<TurtleRsClient, AbstractMap.SimpleEntry<Sha1Sum, Location>>();\n\n\t\t// Hashes marked for removal\n\t\thashesToRemove.stream()\n\t\t\t\t.map(incomingHashes::remove)\n\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t.map(HashInfo::getTunnels)\n\t\t\t\t.forEach(tunnelId -> tunnelId.forEach(id -> closeTunnel(id, virtualPeersToRemove)));\n\n\t\thashesToRemove.clear();\n\n\t\t// Search requests\n\t\tsearchRequestsOrigins.entrySet().removeIf(entry ->\n\t\t\t\tDuration.between(entry.getValue().getLastUsed(), now).compareTo(SEARCH_REQUEST_LIFETIME) > 0);\n\n\t\t// Tunnel requests\n\t\ttunnelRequestsOrigins.entrySet().removeIf(entry ->\n\t\t\t\tDuration.between(entry.getValue().getLastUsed(), now).compareTo(TUNNEL_REQUEST_LIFETIME) > 0);\n\n\t\t// Tunnels\n\t\tlocalTunnels.entrySet().stream()\n\t\t\t\t.filter(entry -> Duration.between(entry.getValue().getLastUsed(), now).compareTo(MAX_TUNNEL_IDLE_TIME) > 0)\n\t\t\t\t.forEach(entry -> closeTunnel(entry.getKey(), virtualPeersToRemove));\n\n\t\t// Remove all the virtual peer ids from the clients\n\t\tvirtualPeersToRemove.forEach((client, entry) -> client.removeVirtualPeer(entry.getKey(), entry.getValue()));\n\t}\n\n\tprivate void closeTunnel(int id, Map<TurtleRsClient, AbstractMap.SimpleEntry<Sha1Sum, Location>> sourcesToRemove)\n\t{\n\t\tlog.debug(\"Closing tunnel {}\", id);\n\t\tvar tunnel = localTunnels.remove(id);\n\n\t\tif (tunnel == null)\n\t\t{\n\t\t\tlog.error(\"Cannot close tunnel {} because it doesn't exist\", id);\n\t\t\treturn;\n\t\t}\n\n\t\tif (tunnel.getSource().equals(ownLocation))\n\t\t{\n\t\t\t// This is a starting tunnel.\n\n\t\t\t// Remove the virtual peer from the virtual peers list\n\t\t\tvirtualPeers.remove(tunnel.getVirtualLocation().getLocationIdentifier());\n\n\t\t\t// Remove the tunnel id from the file hash\n\t\t\tOptional.ofNullable(incomingHashes.get(tunnel.getHash())).ifPresent(hashInfo -> {\n\t\t\t\thashInfo.removeTunnel(id);\n\t\t\t\tsourcesToRemove.put(hashInfo.getClient(), new AbstractMap.SimpleEntry<>(tunnel.getHash(), tunnel.getVirtualLocation()));\n\t\t\t});\n\t\t}\n\t\telse if (tunnel.getDestination().equals(ownLocation))\n\t\t{\n\t\t\t// This is an ending tunnel.\n\n\t\t\tvar client = outgoingTunnelClients.remove(id);\n\t\t\tif (client != null)\n\t\t\t{\n\t\t\t\tsourcesToRemove.put(client, new AbstractMap.SimpleEntry<>(tunnel.getHash(), tunnel.getVirtualLocation()));\n\n\t\t\t\t// Remove associated virtual peers\n\t\t\t\tvirtualPeers.remove(tunnel.getVirtualLocation().getLocationIdentifier());\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void estimateSpeedIfNeeded()\n\t{\n\t\tvar now = Instant.now();\n\t\tif (Duration.between(lastSpeedEstimation, now).compareTo(SPEED_ESTIMATE_TIME) <= 0)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tlastSpeedEstimation = now;\n\n\t\tlocalTunnels.forEach((_, tunnel) -> {\n\t\t\tvar speedEstimate = tunnel.getTransferredBytes() / (double) SPEED_ESTIMATE_TIME.toSeconds();\n\t\t\ttunnel.setSpeedBps(0.75 * tunnel.getSpeedBps() + 0.25 * speedEstimate);\n\t\t\ttunnel.clearTransferredBytes();\n\t\t});\n\t}\n\n\tpublic TurtleStatistics getStatistics()\n\t{\n\t\treturn turtleStatistics.getStatistics();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/TurtleStatistics.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\n/**\n * Records statistics for Turtle.\n * <p>\n * Everything is in bytes per seconds.\n */\npublic class TurtleStatistics\n{\n\tprivate float forwardTotal;\n\n\tprivate float dataUpload;\n\tprivate float dataDownload;\n\n\tprivate float tunnelRequestsUpload;\n\tprivate float tunnelRequestsDownload;\n\n\tprivate float searchRequestsUpload;\n\tprivate float searchRequestsDownload;\n\n\tprivate float totalUpload;\n\tprivate float totalDownload;\n\n\tpublic TurtleStatistics()\n\t{\n\t}\n\n\tprivate TurtleStatistics(TurtleStatistics from)\n\t{\n\t\tforwardTotal = from.forwardTotal;\n\n\t\tdataUpload = from.dataUpload;\n\t\tdataDownload = from.dataDownload;\n\n\t\ttunnelRequestsUpload = from.tunnelRequestsUpload;\n\t\ttunnelRequestsDownload = from.tunnelRequestsDownload;\n\n\t\tsearchRequestsUpload = from.searchRequestsUpload;\n\t\tsearchRequestsDownload = from.searchRequestsDownload;\n\n\t\ttotalUpload = from.totalUpload;\n\t\ttotalDownload = from.totalDownload;\n\t}\n\n\tpublic synchronized void reset()\n\t{\n\t\tforwardTotal = 0.0f;\n\n\t\tdataUpload = 0.0f;\n\t\tdataDownload = 0.0f;\n\n\t\ttunnelRequestsUpload = 0.0f;\n\t\ttunnelRequestsDownload = 0.0f;\n\n\t\tsearchRequestsUpload = 0.0f;\n\t\tsearchRequestsDownload = 0.0f;\n\n\t\ttotalUpload = 0.0f;\n\t\ttotalDownload = 0.0f;\n\t}\n\n\tpublic synchronized TurtleStatistics multiply(float number)\n\t{\n\t\tvar result = new TurtleStatistics(this);\n\n\t\tresult.forwardTotal *= number;\n\n\t\tresult.dataUpload *= number;\n\t\tresult.dataDownload *= number;\n\n\t\tresult.tunnelRequestsUpload *= number;\n\t\tresult.tunnelRequestsDownload *= number;\n\n\t\tresult.searchRequestsUpload *= number;\n\t\tresult.searchRequestsDownload *= number;\n\n\t\tresult.totalUpload *= number;\n\t\tresult.totalDownload *= number;\n\n\t\treturn result;\n\t}\n\n\tpublic synchronized TurtleStatistics add(float number)\n\t{\n\t\tvar result = new TurtleStatistics(this);\n\n\t\tresult.forwardTotal += number;\n\n\t\tresult.dataUpload += number;\n\t\tresult.dataDownload += number;\n\n\t\tresult.tunnelRequestsUpload += number;\n\t\tresult.tunnelRequestsDownload += number;\n\n\t\tresult.searchRequestsUpload += number;\n\t\tresult.searchRequestsDownload += number;\n\n\t\tresult.totalUpload += number;\n\t\tresult.totalDownload += number;\n\n\t\treturn result;\n\t}\n\n\tpublic synchronized TurtleStatistics add(TurtleStatistics other)\n\t{\n\t\tvar result = new TurtleStatistics(this);\n\n\t\tresult.forwardTotal += other.forwardTotal;\n\n\t\tresult.dataUpload += other.dataUpload;\n\t\tresult.dataDownload += other.dataDownload;\n\n\t\tresult.tunnelRequestsUpload += other.tunnelRequestsUpload;\n\t\tresult.tunnelRequestsDownload += other.tunnelRequestsDownload;\n\n\t\tresult.searchRequestsUpload += other.searchRequestsUpload;\n\t\tresult.searchRequestsDownload += other.searchRequestsDownload;\n\n\t\tresult.totalUpload += other.totalUpload;\n\t\tresult.totalDownload += other.totalDownload;\n\n\t\treturn result;\n\t}\n\n\tpublic synchronized void addToTunnelRequestsDownload(int size)\n\t{\n\t\ttunnelRequestsDownload += size;\n\t}\n\n\tpublic synchronized void addToTunnelRequestsUpload(int size)\n\t{\n\t\ttunnelRequestsUpload += size;\n\t}\n\n\tpublic synchronized void addToSearchRequestsDownload(int size)\n\t{\n\t\tsearchRequestsDownload += size;\n\t}\n\n\tpublic synchronized void addToSearchRequestsUpload(int size)\n\t{\n\t\tsearchRequestsUpload += size;\n\t}\n\n\tpublic synchronized void addToForwardTotal(int size)\n\t{\n\t\tforwardTotal += size;\n\t}\n\n\tpublic synchronized void addToDataDownload(int size)\n\t{\n\t\tdataDownload += size;\n\t}\n\n\tpublic synchronized void addToDataUpload(int size)\n\t{\n\t\tdataUpload += size;\n\t}\n\n\tpublic float getForwardTotal()\n\t{\n\t\treturn forwardTotal;\n\t}\n\n\tpublic float getDataUpload()\n\t{\n\t\treturn dataUpload;\n\t}\n\n\tpublic float getDataDownload()\n\t{\n\t\treturn dataDownload;\n\t}\n\n\tpublic float getTunnelRequestsUpload()\n\t{\n\t\treturn tunnelRequestsUpload;\n\t}\n\n\tpublic float getTunnelRequestsDownload()\n\t{\n\t\treturn tunnelRequestsDownload;\n\t}\n\n\tpublic float getSearchRequestsUpload()\n\t{\n\t\treturn searchRequestsUpload;\n\t}\n\n\tpublic float getSearchRequestsDownload()\n\t{\n\t\treturn searchRequestsDownload;\n\t}\n\n\tpublic float getTotalUpload()\n\t{\n\t\treturn totalUpload;\n\t}\n\n\tpublic float getTotalDownload()\n\t{\n\t\treturn totalDownload;\n\t}\n\n\tpublic TurtleStatistics getStatistics()\n\t{\n\t\treturn new TurtleStatistics(this);\n\t}\n\n\t@Override\n\tpublic synchronized String toString()\n\t{\n\t\treturn \"TrafficStatistics [forwardTotal=\" + forwardTotal +\n\t\t\t\t\", dataUpload=\" + dataUpload +\n\t\t\t\t\", dataDownload=\" + dataDownload +\n\t\t\t\t\", tunnelRequestsUpload=\" + tunnelRequestsUpload +\n\t\t\t\t\", tunnelRequestsDownload=\" + tunnelRequestsDownload +\n\t\t\t\t\", searchRequestsUpload=\" + searchRequestsUpload +\n\t\t\t\t\", searchRequestsDownload=\" + searchRequestsDownload +\n\t\t\t\t\", totalUpload=\" + totalUpload +\n\t\t\t\t\", totalDownload=\" + totalDownload + \"]\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/VirtualLocation.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.common.id.LocationIdentifier;\n\n/**\n * Handles Virtual Locations, which are \"distant\" locations in the Turtle network (it could be your direct peer to, it's impossible to know).\n */\nfinal class VirtualLocation\n{\n\tprivate VirtualLocation()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Creates a virtual location out of a tunnel id.\n\t * <p>\n\t * A virtual location performs more or less like a normal location.\n\t *\n\t * @param tunnelId the tunnel id\n\t * @return a virtual location\n\t */\n\tpublic static Location fromTunnel(int tunnelId)\n\t{\n\t\tvar buf = new byte[LocationIdentifier.LENGTH];\n\n\t\tfor (var i = 0; i < 4; i++)\n\t\t{\n\t\t\tbuf[i] = (byte) ((tunnelId >> ((3 - i) * 8)) & 0xff);\n\t\t}\n\t\treturn Location.createLocation(\"TurtleVirtualLocation\", new LocationIdentifier(buf));\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/doc-files/search.puml",
    "content": "@startuml\n'https://plantuml.com/class-diagram\n\nabstract class TurtleSearchRequestItem {\n\tint requestId;\n\tshort depth;\n\tint getRequestId();\n\tshort getDepth();\n}\n\nabstract class TurtleFileSearchRequestItem {\n\tString getKeywords();\n}\n\nabstract class TurtleSearchResultItem {\n\tint requestId;\n\tshort depth;\n\tgetRequestId();\n}\n\nclass TurtleStringSearchRequestItem {\n\tString search;\n\tString getSearch();\n}\n\nclass TurtleRegExpSearchRequestItem {\n\tList<Byte> tokens;\n\tList<Integer> ints;\n\tList<String> strings;\n\tString getKeywords()\n}\n\nclass TurtleGenericSearchRequestItem {\n\tshort serviceId;\n\tbyte requestType;\n\tbyte[] searchData;\n\tString getKeywords();\n}\n\nclass TurtleFileSearchResultItem {\n\tList<TurtleFileInfo> results;\n\tList<TurtleFileInfo> getResults();\n}\n\nclass TurtleGenericSearchResultItem {\n\tbyte[] searchData;\n\tbyte[] getSearchData();\n}\n\nTurtleSearchRequestItem <|-- TurtleFileSearchRequestItem\nTurtleSearchRequestItem <|-- TurtleGenericSearchRequestItem\nTurtleFileSearchRequestItem <|-- TurtleStringSearchRequestItem\nTurtleFileSearchRequestItem <|-- TurtleRegExpSearchRequestItem\nTurtleSearchResultItem <|-- TurtleFileSearchResultItem\nTurtleSearchResultItem <|-- TurtleGenericSearchResultItem\n\n@enduml"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TunnelDirection.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\n/**\n * The direction of the tunnel. Either {@link #CLIENT} or {@link #SERVER}.\n * If for example a packet has \"client\" set, then it means whoever sent it is a client.\n */\npublic enum TunnelDirection\n{\n\t/**\n\t * A client, For example when downloading a file from a remote node or when we started a distant chat.\n\t */\n\tCLIENT,\n\n\t/**\n\t * A server, for example when serving a file to a remote node or receiving a distant chat.\n\t */\n\tSERVER\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleFileInfo.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static io.xeres.app.xrs.serialization.TlvType.STR_NAME;\n\n/**\n * The representation of a file by turtle.\n */\npublic class TurtleFileInfo\n{\n\t@RsSerialized\n\tprivate long fileSize;\n\n\t@RsSerialized\n\tprivate Sha1Sum fileHash;\n\n\t@RsSerialized(tlvType = STR_NAME)\n\tprivate String fileName;\n\n\tpublic TurtleFileInfo()\n\t{\n\t\t// Needed\n\t}\n\n\tpublic TurtleFileInfo(String fileName, Sha1Sum fileHash, long fileSize)\n\t{\n\t\tthis.fileName = fileName;\n\t\tthis.fileHash = fileHash;\n\t\tthis.fileSize = fileSize;\n\t}\n\n\tpublic long getFileSize()\n\t{\n\t\treturn fileSize;\n\t}\n\n\tpublic Sha1Sum getFileHash()\n\t{\n\t\treturn fileHash;\n\t}\n\n\tpublic String getFileName()\n\t{\n\t\treturn fileName;\n\t}\n\n\tpublic int getSize()\n\t{\n\t\treturn Long.BYTES + Sha1Sum.LENGTH + TLV_HEADER_SIZE + fileName.length();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileInfo{\" +\n\t\t\t\t\"fileSize=\" + fileSize +\n\t\t\t\t\", fileHash=\" + fileHash +\n\t\t\t\t\", fileName='\" + fileName + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleFileSearchRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\n/**\n * The superclass of all file search requests.\n */\npublic abstract class TurtleFileSearchRequestItem extends TurtleSearchRequestItem\n{\n\t@Override\n\tpublic String getKeywords()\n\t{\n\t\treturn \"\";\n\t}\n\n\t@Override\n\tpublic TurtleFileSearchRequestItem clone()\n\t{\n\t\treturn (TurtleFileSearchRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileSearchRequestItem{}\";\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleFileSearchResultItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * Used to provide the results of a file search.\n */\npublic class TurtleFileSearchResultItem extends TurtleSearchResultItem\n{\n\t@RsSerialized\n\tprivate List<TurtleFileInfo> results = new ArrayList<>();\n\n\tpublic TurtleFileSearchResultItem()\n\t{\n\t\t// Needed\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\t@Override\n\tpublic int getCount()\n\t{\n\t\treturn results.size();\n\t}\n\n\t@Override\n\tpublic void trim(int size)\n\t{\n\t\tif (size < results.size())\n\t\t{\n\t\t\tresults = results.subList(0, size);\n\t\t}\n\t}\n\n\tpublic List<TurtleFileInfo> getResults()\n\t{\n\t\treturn results;\n\t}\n\n\tpublic void addFileInfo(TurtleFileInfo fileInfo)\n\t{\n\t\tresults.add(fileInfo);\n\t}\n\n\t@Override\n\tpublic TurtleFileSearchResultItem clone()\n\t{\n\t\treturn (TurtleFileSearchResultItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleFileSearchResultItem{\" +\n\t\t\t\t\"results=\" + results +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericDataItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\n/**\n * Used by any service to pass on arbitrary data into a tunnel.\n */\npublic class TurtleGenericDataItem extends TurtleGenericTunnelItem\n{\n\t/**\n\t * The data.\n\t */\n\t@RsSerialized\n\tprivate byte[] tunnelData;\n\n\tpublic TurtleGenericDataItem()\n\t{\n\t\t// Required\n\t}\n\n\tpublic TurtleGenericDataItem(byte[] data)\n\t{\n\t\ttunnelData = data;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 10;\n\t}\n\n\t@Override\n\tpublic boolean shouldStampTunnel()\n\t{\n\t\treturn true;\n\t}\n\n\tpublic byte[] getTunnelData()\n\t{\n\t\treturn tunnelData;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleGenericDataItem{\" +\n\t\t\t\t\"tunnelData.length=\" + (tunnelData == null ? \"[null]\" : tunnelData.length) +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic TurtleGenericDataItem clone()\n\t{\n\t\treturn (TurtleGenericDataItem) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericFastDataItem.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.item.ItemPriority;\n\n/**\n * Used by any service to pass on arbitrary data into a tunnel.\n * <p>\n * Same as {@link TurtleGenericDataItem} but with a fast priority. Can be\n * used for example by distant chat.\n */\npublic class TurtleGenericFastDataItem extends TurtleGenericDataItem\n{\n\tpublic TurtleGenericFastDataItem()\n\t{\n\t\t// Required\n\t}\n\n\tpublic TurtleGenericFastDataItem(byte[] data)\n\t{\n\t\tsuper(data);\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 22;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.INTERACTIVE.getPriority();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleGenericFastDataItem{\" +\n\t\t\t\t\"tunnelData.length=\" + (getTunnelData() == null ? \"[null]\" : getTunnelData().length) +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic TurtleGenericFastDataItem clone()\n\t{\n\t\treturn (TurtleGenericFastDataItem) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericSearchRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\n/**\n * Used to do searches in a generic way.\n */\npublic class TurtleGenericSearchRequestItem extends TurtleSearchRequestItem\n{\n\t/**\n\t * The service to search.\n\t */\n\t@RsSerialized\n\tprivate short serviceId;\n\n\t/**\n\t * The type of request. This is used to limite the number of responses.\n\t */\n\t@RsSerialized\n\tprivate byte requestType;\n\n\t@RsSerialized\n\tprivate byte[] searchData; // XXX: not sure that's correct...\n\n\t@Override\n\tpublic String getKeywords()\n\t{\n\t\treturn \"\"; // XXX: how to do that?\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleGenericSearchRequestItem()\n\t{\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 11;\n\t}\n\n\tpublic short getServiceId()\n\t{\n\t\treturn serviceId;\n\t}\n\n\tpublic byte getRequestType()\n\t{\n\t\treturn requestType;\n\t}\n\n\tpublic byte[] getSearchData()\n\t{\n\t\treturn searchData;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleGenericSearchRequestItem{\" +\n\t\t\t\t\"requestId=\" + getRequestId() +\n\t\t\t\t\", depth=\" + getDepth() +\n\t\t\t\t\", serviceId=\" + serviceId +\n\t\t\t\t\", requestType=\" + requestType +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic TurtleGenericSearchRequestItem clone()\n\t{\n\t\treturn (TurtleGenericSearchRequestItem) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericSearchResultItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport java.util.Arrays;\n\n/**\n * Used to provide a result for a generic search.\n */\npublic class TurtleGenericSearchResultItem extends TurtleSearchResultItem\n{\n\t@RsSerialized\n\tprivate byte[] searchData; // XXX: not sure it's the right data type\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleGenericSearchResultItem()\n\t{\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 12;\n\t}\n\n\tpublic byte[] getSearchData()\n\t{\n\t\treturn searchData;\n\t}\n\n\t@Override\n\tpublic int getCount()\n\t{\n\t\treturn searchData.length / 50; // XXX: this is an estimate... probably wrong\n\t}\n\n\t@Override\n\tpublic void trim(int size)\n\t{\n\t\t// XXX: implement?\n\t}\n\n\t@Override\n\tpublic TurtleGenericSearchResultItem clone()\n\t{\n\t\treturn (TurtleGenericSearchResultItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleGenericSearchResultItem{\" +\n\t\t\t\t\"searchData=\" + Arrays.toString(searchData) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleGenericTunnelItem.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\n/**\n * The superclass of generic turtle packets.\n */\npublic abstract class TurtleGenericTunnelItem extends Item\n{\n\t/**\n\t * The tunnel id.\n\t */\n\t@RsSerialized\n\tprivate int tunnelId;\n\n\t/**\n\t * The direction of the tunnel (client or server). This field is optional and only used\n\t * by the implementation if needed.\n\t */\n\tprivate TunnelDirection direction;\n\n\tpublic abstract boolean shouldStampTunnel();\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.TURTLE_ROUTER.getType();\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.NORMAL.getPriority();\n\t}\n\n\tpublic int getTunnelId()\n\t{\n\t\treturn tunnelId;\n\t}\n\n\tpublic void setTunnelId(int tunnelId)\n\t{\n\t\tthis.tunnelId = tunnelId;\n\t}\n\n\tpublic TunnelDirection getDirection()\n\t{\n\t\treturn direction;\n\t}\n\n\tpublic void setDirection(TunnelDirection direction)\n\t{\n\t\tthis.direction = direction;\n\t}\n\n\t@Override\n\tpublic TurtleGenericTunnelItem clone()\n\t{\n\t\treturn (TurtleGenericTunnelItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleGenericTunnelItem{\" +\n\t\t\t\t\"tunnelId=\" + tunnelId +\n\t\t\t\t\", direction=\" + direction +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleRegExpSearchRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.netty.buffer.ByteBuf;\nimport io.xeres.app.util.expression.Expression;\nimport io.xeres.app.util.expression.ExpressionMapper;\nimport io.xeres.app.xrs.serialization.RsSerializable;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.serialization.Serializer;\nimport io.xeres.app.xrs.serialization.TlvType;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\n/**\n * Used to do a regexp search for a file.\n */\npublic class TurtleRegExpSearchRequestItem extends TurtleFileSearchRequestItem implements RsSerializable\n{\n\tprivate static final int MAX_TOKENS_LIMIT = 256;\n\n\tprivate List<Byte> tokens;\n\n\tprivate List<Integer> ints;\n\n\tprivate List<String> strings;\n\n\tprivate String keywords; // Not serialized\n\tprivate List<Expression> expressions; // Not serialized\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleRegExpSearchRequestItem()\n\t{\n\t}\n\n\tpublic TurtleRegExpSearchRequestItem(List<Byte> tokens, List<Integer> ints, List<String> strings)\n\t{\n\t\tthis.tokens = tokens;\n\t\tthis.ints = ints;\n\t\tthis.strings = strings;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 9;\n\t}\n\n\tpublic List<Byte> getTokens()\n\t{\n\t\treturn tokens;\n\t}\n\n\tpublic List<Integer> getInts()\n\t{\n\t\treturn ints;\n\t}\n\n\tpublic List<String> getStrings()\n\t{\n\t\treturn strings;\n\t}\n\n\tpublic List<Expression> getExpressions()\n\t{\n\t\tbuildExpressionsIfNeeded();\n\t\treturn expressions;\n\t}\n\n\t@Override\n\tpublic String getKeywords()\n\t{\n\t\tif (keywords == null)\n\t\t{\n\t\t\tbuildExpressionsIfNeeded();\n\t\t\tkeywords = expressions.stream()\n\t\t\t\t\t.map(Object::toString)\n\t\t\t\t\t.collect(Collectors.joining(\" \"));\n\t\t}\n\t\treturn keywords;\n\t}\n\n\tprivate void buildExpressionsIfNeeded()\n\t{\n\t\tif (expressions == null)\n\t\t{\n\t\t\texpressions = ExpressionMapper.toExpressions(this);\n\t\t}\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleRegExpSearchRequestItem{\" +\n\t\t\t\t\"requestId=\" + getRequestId() +\n\t\t\t\t\", depth=\" + getDepth() +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic TurtleRegExpSearchRequestItem clone()\n\t{\n\t\treturn (TurtleRegExpSearchRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic int writeObject(ByteBuf buf, Set<SerializationFlags> serializationFlags)\n\t{\n\t\tvar size = 0;\n\n\t\tsize += Serializer.serializeAnnotatedFields(buf, this);\n\n\t\tsize += Serializer.serialize(buf, tokens.size());\n\t\tsize += tokens.stream()\n\t\t\t\t.mapToInt(value -> Serializer.serialize(buf, value))\n\t\t\t\t.sum();\n\n\t\tsize += Serializer.serialize(buf, ints.size());\n\t\tsize += ints.stream()\n\t\t\t\t.mapToInt(value -> Serializer.serialize(buf, value))\n\t\t\t\t.sum();\n\n\t\tsize += Serializer.serialize(buf, strings.size());\n\t\tsize += strings.stream()\n\t\t\t\t.mapToInt(value -> Serializer.serialize(buf, TlvType.STR_VALUE, value))\n\t\t\t\t.sum();\n\n\t\treturn size;\n\t}\n\n\t@Override\n\tpublic void readObject(ByteBuf buf)\n\t{\n\t\tSerializer.deserializeAnnotatedFields(buf, this);\n\n\t\tvar length = validateTokenLimit(Serializer.deserializeInt(buf));\n\t\ttokens = new ArrayList<>(length);\n\t\tfor (var i = 0; i < length; i++)\n\t\t{\n\t\t\ttokens.add(Serializer.deserializeByte(buf));\n\t\t}\n\n\t\tlength = validateTokenLimit(Serializer.deserializeInt(buf));\n\t\tints = new ArrayList<>(length);\n\t\tfor (var i = 0; i < length; i++)\n\t\t{\n\t\t\tints.add(Serializer.deserializeInt(buf));\n\t\t}\n\n\t\tlength = validateTokenLimit(Serializer.deserializeInt(buf));\n\t\tstrings = new ArrayList<>(length);\n\t\tfor (var i = 0; i < length; i++)\n\t\t{\n\t\t\tstrings.add((String) Serializer.deserialize(buf, TlvType.STR_VALUE));\n\t\t}\n\t}\n\n\tprivate static int validateTokenLimit(int size)\n\t{\n\t\tif (size >= MAX_TOKENS_LIMIT)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Maximum search tokens exceeded\");\n\t\t}\n\t\treturn size;\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleSearchRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\n/**\n * The superclass of all search request items.\n * <p>\n * <img src=\"../doc-files/search.png\" alt=\"Search class diagram\">\n */\npublic abstract class TurtleSearchRequestItem extends Item\n{\n\t@RsSerialized\n\tprivate int requestId;\n\n\t@RsSerialized\n\tprivate short depth;\n\n\tpublic abstract String getKeywords();\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.TURTLE_ROUTER.getType();\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.HIGH.getPriority();\n\t}\n\n\tpublic int getRequestId()\n\t{\n\t\treturn requestId;\n\t}\n\n\tpublic void setRequestId(int requestId)\n\t{\n\t\tthis.requestId = requestId;\n\t}\n\n\tpublic short getDepth()\n\t{\n\t\treturn depth;\n\t}\n\n\tpublic void setDepth(short depth)\n\t{\n\t\tthis.depth = depth;\n\t}\n\n\t@Override\n\tpublic TurtleSearchRequestItem clone()\n\t{\n\t\treturn (TurtleSearchRequestItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleSearchRequestItem{\" +\n\t\t\t\t\"requestId=\" + requestId +\n\t\t\t\t\", depth=\" + depth +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleSearchResultItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\n/**\n * The superclass of all search result items.\n */\npublic abstract class TurtleSearchResultItem extends Item\n{\n\t@RsSerialized\n\tprivate int requestId;\n\n\t@SuppressWarnings(\"unused\")\n\t@RsSerialized\n\tprivate short depth; // Always set to 0, not used\n\n\tpublic abstract int getCount();\n\n\tpublic abstract void trim(int size);\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.TURTLE_ROUTER.getType();\n\t}\n\n\tpublic int getRequestId()\n\t{\n\t\treturn requestId;\n\t}\n\n\tpublic void setRequestId(int requestId)\n\t{\n\t\tthis.requestId = requestId;\n\t}\n\n\t@Override\n\tpublic TurtleSearchResultItem clone()\n\t{\n\t\treturn (TurtleSearchResultItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleSearchResultItem{\" +\n\t\t\t\t\"requestId=\" + requestId +\n\t\t\t\t\", depth=\" + depth +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleStringSearchRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.serialization.RsClassSerializedReversed;\nimport io.xeres.app.xrs.serialization.RsSerialized;\n\nimport static io.xeres.app.xrs.serialization.TlvType.STR_VALUE;\n\n/**\n * Used to do a string search for a file.\n */\n@RsClassSerializedReversed\npublic class TurtleStringSearchRequestItem extends TurtleFileSearchRequestItem\n{\n\t/**\n\t * The keywords to search for. Separated by spaces.\n\t */\n\t@RsSerialized(tlvType = STR_VALUE)\n\tprivate String keywords;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleStringSearchRequestItem()\n\t{\n\t}\n\n\tpublic TurtleStringSearchRequestItem(String keywords)\n\t{\n\t\tthis.keywords = keywords;\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic String getKeywords()\n\t{\n\t\treturn keywords;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleStringSearchRequestItem{\" +\n\t\t\t\t\"search='\" + keywords + '\\'' +\n\t\t\t\t\", requestId=\" + getRequestId() +\n\t\t\t\t\", depth=\" + getDepth() +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic TurtleStringSearchRequestItem clone()\n\t{\n\t\treturn (TurtleStringSearchRequestItem) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleTunnelRequestItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\n/**\n * Used for opening a tunnel.\n */\npublic class TurtleTunnelRequestItem extends Item\n{\n\t/**\n\t * Hash to match.\n\t */\n\t@RsSerialized\n\tprivate Sha1Sum hash;\n\n\t/**\n\t * Randomly generated request id.\n\t */\n\t@RsSerialized\n\tprivate int requestId;\n\n\t/**\n\t * Incomplete tunnel id that will be completed at destination.\n\t */\n\t@RsSerialized\n\tprivate int partialTunnelId;\n\n\t/**\n\t * Used for limiting the search depth.\n\t */\n\t@RsSerialized\n\tprivate short depth;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleTunnelRequestItem()\n\t{\n\t}\n\n\tpublic TurtleTunnelRequestItem(Sha1Sum hash, int requestId, int partialTunnelId)\n\t{\n\t\tthis.hash = hash;\n\t\tthis.requestId = requestId;\n\t\tthis.partialTunnelId = partialTunnelId;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.TURTLE_ROUTER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\tpublic Sha1Sum getHash()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic int getRequestId()\n\t{\n\t\treturn requestId;\n\t}\n\n\tpublic int getPartialTunnelId()\n\t{\n\t\treturn partialTunnelId;\n\t}\n\n\tpublic void setPartialTunnelId(int partialTunnelId)\n\t{\n\t\tthis.partialTunnelId = partialTunnelId;\n\t}\n\n\tpublic short getDepth()\n\t{\n\t\treturn depth;\n\t}\n\n\tpublic void setDepth(short depth)\n\t{\n\t\tthis.depth = depth;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleTunnelOpenItem{\" +\n\t\t\t\t\"fileHash=\" + hash +\n\t\t\t\t\", requestId=\" + requestId +\n\t\t\t\t\", partialTunnelId=\" + partialTunnelId +\n\t\t\t\t\", depth=\" + depth +\n\t\t\t\t'}';\n\t}\n\n\t@Override\n\tpublic TurtleTunnelRequestItem clone()\n\t{\n\t\treturn (TurtleTunnelRequestItem) super.clone();\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/turtle/item/TurtleTunnelResultItem.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\n/**\n * Used for acknowledging that a tunnel has been opened.\n */\npublic class TurtleTunnelResultItem extends Item\n{\n\t/**\n\t * The id of the tunnel. Should be identical for a tunnel between two same peers for the same hash.\n\t */\n\t@RsSerialized\n\tprivate int tunnelId;\n\n\t/**\n\t * Randomly generated request id corresponding to the initial request.\n\t */\n\t@RsSerialized\n\tprivate int requestId;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic TurtleTunnelResultItem()\n\t{\n\t}\n\n\tpublic TurtleTunnelResultItem(int tunnelId, int requestId)\n\t{\n\t\tthis.tunnelId = tunnelId;\n\t\tthis.requestId = requestId;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.TURTLE_ROUTER.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 4;\n\t}\n\n\tpublic int getTunnelId()\n\t{\n\t\treturn tunnelId;\n\t}\n\n\tpublic int getRequestId()\n\t{\n\t\treturn requestId;\n\t}\n\n\t@Override\n\tpublic TurtleTunnelResultItem clone()\n\t{\n\t\treturn (TurtleTunnelResultItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"TurtleTunnelOkItem{\" +\n\t\t\t\t\"tunnelId=\" + tunnelId +\n\t\t\t\t\", requestId=\" + requestId +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/voip/LockBasedSingleEntrySupplier.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.voip;\n\nimport java.util.concurrent.locks.Condition;\nimport java.util.concurrent.locks.Lock;\nimport java.util.concurrent.locks.ReentrantLock;\nimport java.util.function.Supplier;\n\npublic class LockBasedSingleEntrySupplier implements Supplier<byte[]>\n{\n\tprivate byte[] buffer;\n\tprivate boolean dataAvailable;\n\tprivate final Lock lock = new ReentrantLock();\n\tprivate final Condition dataPresent = lock.newCondition();\n\tprivate final Condition spaceAvailable = lock.newCondition();\n\n\t@Override\n\tpublic byte[] get()\n\t{\n\t\tlock.lock();\n\t\ttry\n\t\t{\n\t\t\twhile (!dataAvailable)\n\t\t\t{\n\t\t\t\tdataPresent.await();\n\t\t\t}\n\t\t\tbyte[] result = buffer;\n\t\t\tbuffer = null;\n\t\t\tdataAvailable = false;\n\t\t\tspaceAvailable.signal();\n\t\t\treturn result;\n\t\t}\n\t\tcatch (InterruptedException _)\n\t\t{\n\t\t\tThread.currentThread().interrupt();\n\t\t\treturn new byte[0];\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tlock.unlock();\n\t\t}\n\t}\n\n\tpublic void put(byte[] data)\n\t{\n\t\tlock.lock();\n\t\ttry\n\t\t{\n\t\t\twhile (dataAvailable)\n\t\t\t{\n\t\t\t\tspaceAvailable.await();\n\t\t\t}\n\t\t\tbuffer = data;\n\t\t\tdataAvailable = true;\n\t\t\tdataPresent.signal();\n\t\t}\n\t\tcatch (InterruptedException _)\n\t\t{\n\t\t\tThread.currentThread().interrupt();\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tlock.unlock();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/voip/VoipRsService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.voip;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.MessageService;\nimport io.xeres.app.service.audio.AudioService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.RsServiceRegistry;\nimport io.xeres.app.xrs.service.voip.item.VoipDataItem;\nimport io.xeres.app.xrs.service.voip.item.VoipPingItem;\nimport io.xeres.app.xrs.service.voip.item.VoipProtocolItem;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.voip.VoipAction;\nimport io.xeres.common.message.voip.VoipMessage;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\nimport org.xiph.speex.SpeexDecoder;\nimport org.xiph.speex.SpeexEncoder;\n\nimport java.io.StreamCorruptedException;\nimport java.util.Arrays;\n\nimport static io.xeres.app.xrs.service.voip.item.VoipProtocolItem.Protocol.*;\nimport static io.xeres.common.message.MessagePath.voipPrivateDestination;\nimport static io.xeres.common.protocol.xrs.RsServiceType.VOIP;\n\n@Component\npublic class VoipRsService extends RsService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(VoipRsService.class);\n\n\tpublic enum MediaType\n\t{\n\t\tNONE(0),\n\t\tVIDEO(1),\n\t\tAUDIO(2);\n\n\t\tprivate final int type;\n\n\t\tMediaType(int type)\n\t\t{\n\t\t\tthis.type = type;\n\t\t}\n\n\t\tpublic int getType()\n\t\t{\n\t\t\treturn type;\n\t\t}\n\n\t\tpublic static MediaType ofType(int type)\n\t\t{\n\t\t\treturn Arrays.stream(values())\n\t\t\t\t\t.filter(v -> v.type == type)\n\t\t\t\t\t.findFirst()\n\t\t\t\t\t.orElse(NONE);\n\t\t}\n\t}\n\n\tprivate enum Status\n\t{\n\t\tOFF,\n\t\tCALLING,\n\t\tCALLED,\n\t\tONGOING\n\t}\n\n\tprivate final PeerConnectionManager peerConnectionManager;\n\tprivate final AudioService audioService;\n\tprivate final MessageService messageService;\n\tprivate final LocationService locationService;\n\n\tprivate SpeexEncoder speexEncoder;\n\tprivate SpeexDecoder speexDecoder;\n\n\tprivate final LockBasedSingleEntrySupplier audioSupplier = new LockBasedSingleEntrySupplier();\n\n\tprivate LocationIdentifier remoteLocationIdentifier;\n\tprivate Status status = Status.OFF;\n\n\tVoipRsService(RsServiceRegistry rsServiceRegistry, PeerConnectionManager peerConnectionManager, AudioService audioService, MessageService messageService, LocationService locationService)\n\t{\n\t\tsuper(rsServiceRegistry);\n\t\tthis.peerConnectionManager = peerConnectionManager;\n\t\tthis.audioService = audioService;\n\t\tthis.messageService = messageService;\n\t\tthis.locationService = locationService;\n\t}\n\n\t@Override\n\tpublic RsServiceType getServiceType()\n\t{\n\t\treturn VOIP;\n\t}\n\n\t@Override\n\tpublic void handleItem(PeerConnection sender, Item item)\n\t{\n\t\tswitch (item)\n\t\t{\n\t\t\tcase VoipProtocolItem voipProtocolItem -> handleProtocolItem(sender, voipProtocolItem);\n\t\t\tcase VoipDataItem voipDataItem -> handleDataItem(sender, voipDataItem);\n\t\t\tcase VoipPingItem _ ->\n\t\t\t{\n\t\t\t} // We just ignore those. We already have enough pinging systems (rtt, heartbeat, ...)\n\t\t\tdefault -> log.debug(\"Unhandled item {}\", item);\n\t\t}\n\t}\n\n\tpublic void call(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow();\n\t\tlog.debug(\"Calling {}...\", location);\n\n\t\tstatus = Status.CALLING;\n\t\tremoteLocationIdentifier = locationIdentifier;\n\n\t\tvar item = new VoipProtocolItem(RING);\n\t\tpeerConnectionManager.writeItem(location, item, this);\n\t}\n\n\tpublic void accept(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow();\n\t\tlog.debug(\"Accepting call from {}\", location);\n\n\t\tremoteLocationIdentifier = locationIdentifier;\n\t\tstatus = Status.ONGOING;\n\n\t\tvar item = new VoipProtocolItem(ACKNOWLEDGE);\n\t\tpeerConnectionManager.writeItem(location, item, this);\n\n\t\topenChannel(location);\n\t}\n\n\tpublic void hangup(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = locationService.findLocationByLocationIdentifier(locationIdentifier).orElseThrow();\n\t\tlog.debug(\"Hanging up on {}\", location);\n\n\t\tstatus = Status.OFF;\n\t\tremoteLocationIdentifier = null;\n\n\t\tvar item = new VoipProtocolItem(CLOSE);\n\t\tpeerConnectionManager.writeItem(location, item, this);\n\n\t\tcloseChannel();\n\t}\n\n\tprivate void handleProtocolItem(PeerConnection sender, VoipProtocolItem item)\n\t{\n\t\tlog.debug(\"Got protocol item {}, status: {}\", item, status);\n\t\tswitch (item.getProtocol())\n\t\t{\n\t\t\tcase RING ->\n\t\t\t{\n\t\t\t\tif (remoteLocationIdentifier == null && status == Status.OFF)\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Got incoming call from {}\", sender);\n\t\t\t\t\tremoteLocationIdentifier = sender.getLocation().getLocationIdentifier();\n\t\t\t\t\tstatus = Status.CALLED;\n\t\t\t\t\tmessageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.RING));\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Got incoming call from {}, but we're already in call, dropping...\", sender);\n\t\t\t\t\tmessageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.CLOSE)); // XXX: not sure if this is understood by RS, check\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase ACKNOWLEDGE ->\n\t\t\t{\n\t\t\t\tif (sender.getLocation().getLocationIdentifier().equals(remoteLocationIdentifier) && status == Status.CALLING)\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Call acknowledged by {}\", sender);\n\t\t\t\t\tstatus = Status.ONGOING;\n\t\t\t\t\tmessageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.ACKNOWLEDGE));\n\t\t\t\t\topenChannel(sender.getLocation());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase CLOSE ->\n\t\t\t{\n\t\t\t\tif (sender.getLocation().getLocationIdentifier().equals(remoteLocationIdentifier) && (status == Status.ONGOING || status == Status.CALLED || status == Status.CALLING))\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Call closed by {}\", sender);\n\t\t\t\t\tremoteLocationIdentifier = null;\n\t\t\t\t\tstatus = Status.OFF;\n\t\t\t\t\tmessageService.sendToConsumers(voipPrivateDestination(), MessageType.NONE, sender.getLocation().getLocationIdentifier(), new VoipMessage(VoipAction.CLOSE));\n\t\t\t\t\tcloseChannel();\n\t\t\t\t}\n\t\t\t}\n\t\t\tdefault -> log.debug(\"Unhandled protocol {}\", item);\n\t\t}\n\t}\n\n\tprivate void handleDataItem(PeerConnection sender, VoipDataItem item)\n\t{\n\t\tif (status == Status.ONGOING && sender.getLocation().getLocationIdentifier().equals(remoteLocationIdentifier))\n\t\t{\n\t\t\taudioSupplier.put(decodeData(item.getData()));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.debug(\"Ignoring data item {} because current peer is {}\", item, remoteLocationIdentifier);\n\t\t}\n\t}\n\n\tprivate void openChannel(Location target)\n\t{\n\t\tspeexEncoder = new SpeexEncoder();\n\t\tspeexEncoder.init(audioService.getSpeexEncoderMode(), 9, audioService.getAudioSampleRate(), audioService.getAudioSampleChannels());\n\n\t\tspeexEncoder.getEncoder().setVbr(true);\n\t\tspeexEncoder.getEncoder().setVbrQuality(9.0f);\n\t\tspeexEncoder.getEncoder().setComplexity(4);\n\t\tspeexEncoder.getEncoder().setDtx(true);\n\n\t\tspeexDecoder = new SpeexDecoder();\n\t\tspeexDecoder.init(audioService.getSpeexEncoderMode(), audioService.getAudioSampleRate(), audioService.getAudioSampleChannels(), true);\n\n\t\taudioService.startPlayingAndRecording(speexEncoder.getFrameSize(),\n\t\t\t\tdata -> peerConnectionManager.writeItem(target, new VoipDataItem(MediaType.AUDIO, encodeData(data)), this),\n\t\t\t\taudioSupplier);\n\t}\n\n\tprivate void closeChannel()\n\t{\n\t\taudioService.stopRecordingAndPlaying();\n\n\t\tspeexEncoder = null;\n\t\tspeexDecoder = null;\n\t}\n\n\tprivate byte[] encodeData(byte[] input)\n\t{\n\t\tif (speexEncoder.processData(input, 0, input.length))\n\t\t{\n\t\t\tvar encodedData = new byte[speexEncoder.getProcessedDataByteSize()];\n\t\t\tspeexEncoder.getProcessedData(encodedData, 0);\n\t\t\treturn encodedData;\n\t\t}\n\t\tlog.error(\"Speex encoding failed\");\n\t\treturn new byte[0];\n\t}\n\n\tprivate byte[] decodeData(byte[] input)\n\t{\n\t\ttry\n\t\t{\n\t\t\tspeexDecoder.processData(input, 0, input.length);\n\n\t\t\tvar decodedData = new byte[speexDecoder.getProcessedDataByteSize()];\n\t\t\tspeexDecoder.getProcessedData(decodedData, 0);\n\t\t\treturn decodedData;\n\t\t}\n\t\tcatch (StreamCorruptedException e)\n\t\t{\n\t\t\tlog.error(\"Speex decoding failed: {}\", e.getMessage());\n\t\t\treturn new byte[0];\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipDataItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.voip.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.voip.VoipRsService.MediaType;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class VoipDataItem extends Item\n{\n\t@RsSerialized\n\tprivate int flags;\n\n\t@RsSerialized\n\tprivate byte[] data;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic VoipDataItem()\n\t{\n\t}\n\n\tpublic VoipDataItem(MediaType mediaType, byte[] data)\n\t{\n\t\tflags = mediaType.getType();\n\t\tthis.data = data;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.VOIP.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 7;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\tpublic MediaType getFlags()\n\t{\n\t\treturn MediaType.ofType(flags);\n\t}\n\n\tpublic byte[] getData()\n\t{\n\t\treturn data;\n\t}\n\n\t@Override\n\tpublic VoipDataItem clone()\n\t{\n\t\treturn (VoipDataItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VoipDataItem{\" +\n\t\t\t\t\"flags=\" + flags +\n\t\t\t\t\", data size=\" + data.length +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipPingItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.voip.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class VoipPingItem extends Item\n{\n\t@RsSerialized\n\tprivate int sequenceNumber;\n\n\t@RsSerialized\n\tprivate long timestamp;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.VOIP.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 1;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\t@Override\n\tpublic VoipPingItem clone()\n\t{\n\t\treturn (VoipPingItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VoipPingItem{\" +\n\t\t\t\t\"sequenceNumber=\" + sequenceNumber +\n\t\t\t\t\", timestamp=\" + timestamp +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipPongItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.voip.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class VoipPongItem extends Item\n{\n\t@RsSerialized\n\tprivate int sequenceNumber;\n\n\t@RsSerialized\n\tprivate long pingTimestamp;\n\n\t@RsSerialized\n\tprivate long pongTimestamp;\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.VOIP.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 2;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\t@Override\n\tpublic VoipPongItem clone()\n\t{\n\t\treturn (VoipPongItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VoipPongItem{\" +\n\t\t\t\t\"sequenceNumber=\" + sequenceNumber +\n\t\t\t\t\", pingTimestamp=\" + pingTimestamp +\n\t\t\t\t\", pongTimestamp=\" + pongTimestamp +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/java/io/xeres/app/xrs/service/voip/item/VoipProtocolItem.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.voip.item;\n\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.ItemPriority;\nimport io.xeres.app.xrs.serialization.RsSerialized;\nimport io.xeres.app.xrs.service.voip.VoipRsService;\nimport io.xeres.common.protocol.xrs.RsServiceType;\n\npublic class VoipProtocolItem extends Item\n{\n\tpublic enum Protocol\n\t{\n\t\t/// Not used.\n\t\tNONE,\n\n\t\t/// Call/Ring.\n\t\tRING,\n\n\t\t/// Pickup/Acknowledge the call.\n\t\tACKNOWLEDGE,\n\n\t\t/// Hangup/Close the call.\n\t\tCLOSE,\n\n\t\t/// Ask for the bandwidth.\n\t\tBANDWIDTH\n\t}\n\n\t@RsSerialized\n\tprivate Protocol protocol;\n\n\t@RsSerialized\n\tprivate int flags;\n\n\t@SuppressWarnings(\"unused\")\n\tpublic VoipProtocolItem()\n\t{\n\t}\n\n\tpublic VoipProtocolItem(Protocol protocol)\n\t{\n\t\tflags = VoipRsService.MediaType.AUDIO.getType();\n\t\tthis.protocol = protocol;\n\t}\n\n\t@Override\n\tpublic int getServiceType()\n\t{\n\t\treturn RsServiceType.VOIP.getType();\n\t}\n\n\t@Override\n\tpublic int getSubType()\n\t{\n\t\treturn 3;\n\t}\n\n\t@Override\n\tpublic int getPriority()\n\t{\n\t\treturn ItemPriority.REALTIME.getPriority();\n\t}\n\n\tpublic Protocol getProtocol()\n\t{\n\t\treturn protocol;\n\t}\n\n\t@Override\n\tpublic VoipProtocolItem clone()\n\t{\n\t\treturn (VoipProtocolItem) super.clone();\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VoipProtocolItem{\" +\n\t\t\t\t\"protocol=\" + protocol +\n\t\t\t\t\", flags=\" + flags +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "app/src/main/javadoc/overview.html",
    "content": "<!--\n  ~ Copyright (c) 2024 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<body>\nThis is the server part of Xeres. It can be run in a standalone way and be accessed from a remote\nUI client.\n</body>"
  },
  {
    "path": "app/src/main/resources/LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<http://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<http://www.gnu.org/philosophy/why-not-lgpl.html>."
  },
  {
    "path": "app/src/main/resources/META-INF/additional-spring-configuration-metadata.json",
    "content": "{\n    \"properties\": [\n        {\n            \"name\": \"xrs.service.rtt.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the RTT service, used to calculate Round Trip Time between peers.\"\n        },\n        {\n            \"name\": \"xrs.service.sliceprobe.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the slice probe service, used to advertise we support incoming packet slicing.\"\n        },\n        {\n            \"name\": \"xrs.service.serviceinfo.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the serviceinfo service, used to advertise which services we support.\"\n        },\n        {\n            \"name\": \"xrs.service.discovery.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the discovery service, used to exchange keys and contacts between locations.\"\n        },\n        {\n            \"name\": \"xrs.service.heartbeat.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the heartbeat service, used to ping a peer to know if it's up but kind of useless as it overlaps with sliceprobe and rtt.\"\n        },\n        {\n            \"name\": \"xrs.service.chat.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the chat service, used for distributed chat, private chat, distant chat, ...\"\n        },\n        {\n            \"name\": \"xrs.service.status.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the status service, used to tell about status (away, busy, online, etc...).\"\n        },\n        {\n            \"name\": \"xrs.service.identity.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the identity service, used to exchange GXS identities.\"\n        },\n        {\n            \"name\": \"xrs.service.turtle.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the turtle service, used for file searching, tunnel building and file transfers.\"\n        },\n        {\n            \"name\": \"xrs.service.forum.enabled\",\n            \"type\": \"java.lang.Boolean\",\n            \"description\": \"Enable the forum service.\"\n        },\n        {\n            \"name\" : \"xrs.service.filetransfer.enabled\",\n            \"type\" : \"java.lang.Boolean\",\n            \"description\" : \"Enable the file transfer service.\"\n        },\n        {\n            \"name\" : \"xrs.service.gxstunnel.enabled\",\n            \"type\" : \"java.lang.Boolean\",\n            \"description\" : \"Enable the gxs tunnel service, used to create distant chats.\"\n        },\n        {\n            \"name\" : \"xrs.service.bandwidth.enabled\",\n            \"type\" : \"java.lang.Boolean\",\n            \"description\" : \"Enable the bandwidth control service, used to advertise our bandwidth.\"\n        },\n        {\n            \"name\" : \"xrs.service.voip.enabled\",\n            \"type\" : \"java.lang.Boolean\",\n            \"description\" : \"Enable the VoIP service, used to voice chat with direct peers.\"\n        },\n        {\n            \"name\" : \"xrs.service.board.enabled\",\n            \"type\" : \"java.lang.Boolean\",\n            \"description\" : \"Enable the boards service.\"\n        },\n        {\n            \"name\" : \"xrs.service.channel.enabled\",\n            \"type\" : \"java.lang.Boolean\",\n            \"description\" : \"Enable the channel service.\"\n        }\n    ]\n}"
  },
  {
    "path": "app/src/main/resources/application-cloud.properties",
    "content": "#\n# Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n# This profile is active when deployed on a cloud environment\n\n"
  },
  {
    "path": "app/src/main/resources/application-dev.properties",
    "content": "#\n# Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n\n# Debug levels\n\n## Default\nlogging.level.io.xeres=DEBUG\n\n## Broadcast discovery\nlogging.level.io.xeres.app.net.bdisc=INFO\n\n## Serializer\n# Enable for serialization debugging\n#logging.level.io.xeres.app.xrs.serialization.*=TRACE\n# Set to TRACE for item serialized content\nlogging.level.io.xeres.app.xrs.item=INFO\n\n## Packet content\n# Outgoing (set to TRACE for packet content)\nlogging.level.io.xeres.app.net.peer.PeerConnectionManager=INFO\n# Incoming (set to DEBUG for incoming item debug, including error stack traces, set to TRACE for packet content)\nlogging.level.io.xeres.app.net.peer.pipeline.PeerHandler=INFO\n\n## WebSocket\nlogging.level.org.springframework.web.socket.config.WebSocketMessageBrokerStats=WARN\n#logging.level.org.springframework.web.socket.*=DEBUG\n\n## JPA\n#spring.jpa.show-sql=true\n#spring.jpa.properties.hibernate.format_sql=true\n#spring.jpa.properties.hibernate.generate_statistics=true\n#logging.level.org.hibernate.SQL=DEBUG\n#logging.level.org.hibernate=DEBUG\n#logging.level.org.springframework.orm.jpa=TRACE\n#logging.level.org.springframework.transaction=TRACE\n\n## WebClient\n#logging.level.org.springframework.web.reactive=DEBUG\n#logging.level.reactor.netty=DEBUG\n#logging.level.org.springframework.http.client.reactive=DEBUG\n#logging.level.reactor.netty.http.client=TRACE\n\n## Sent chat content (set chat service to DEBUG to see the received unparsed text)\n#logging.level.io.xeres.app.api.controller.chat.ChatMessageController=TRACE\n\n## File sharing\nlogging.level.io.xeres.app.service.file.FileService=INFO\n\n## OnDemand loader\nlogging.level.io.xeres.ui.support.loader=INFO\n\n## Services\n\n## Chat\nlogging.level.io.xeres.app.xrs.service.chat=INFO\n## Discovery\nlogging.level.io.xeres.app.xrs.service.discovery=INFO\n## heartbeat\nlogging.level.io.xeres.app.xrs.service.heartbeat=INFO\n## rtt\nlogging.level.io.xeres.app.xrs.service.rtt=INFO\n## serviceinfo\nlogging.level.io.xeres.app.xrs.service.serviceinfo=INFO\n## sliceprobe\nlogging.level.io.xeres.app.xrs.service.sliceprobe=INFO\n## status\nlogging.level.io.xeres.app.xrs.service.status=INFO\n## Gxs\nlogging.level.io.xeres.app.xrs.service.gxs=INFO\n## GxsID\nlogging.level.io.xeres.app.xrs.service.identity=INFO\n## Forums\nlogging.level.io.xeres.app.xrs.service.forum=INFO\n## Channels\nlogging.level.io.xeres.app.xrs.service.channel=INFO\n## Boards\nlogging.level.io.xeres.app.xrs.service.board=INFO\n## Turtle\nlogging.level.io.xeres.app.xrs.service.turtle=INFO\n## GxsTunnels\nlogging.level.io.xeres.app.xrs.service.gxstunnel=DEBUG\n## Bandwidth\nlogging.level.io.xeres.app.xrs.service.bandwidth=INFO\n\n### Other settings\n\n## Actuator\ninfo.java.vm.vendor=${java.vm.vendor}\ninfo.java.version=${java.version}\nmanagement.endpoint.shutdown.access=unrestricted\nmanagement.endpoints.web.exposure.include=*\nmanagement.endpoints.web.base-path=/api/v1/actuator\nmanagement.info.java.enabled=true\nmanagement.info.os.enabled=true\nspringdoc.show-actuator=true\nmanagement.endpoint.env.show-values=always\nmanagement.endpoint.configprops.show-values=always\n\n## Netty\nspring.netty.leak-detection=paranoid\n\n## H2\nspring.h2.console.enabled=true\n\nmanagement.endpoints.jmx.exposure.include=*\n\n## Useful for debugging POST errors\n#logging.level.org.springframework.web=DEBUG\n#logging.level.org.springframework.http=DEBUG\n#logging.level.org.springframework.web.servlet.DispatcherServlet=TRACE"
  },
  {
    "path": "app/src/main/resources/application.properties",
    "content": "#\n# Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\nspring.datasource.driver-class-name=org.h2.Driver\nspring.jpa.open-in-view=true\n\n## Server port, address and SSL mode\n# This cannot be changed here because some components use the property before Spring processes them,\n# so basically the 3 next properties are effectively useless.\n# To set the server port, use the command argument: --control-port=<port>\n# To remove HTTPS use --no-https\nserver.port=6232\nserver.address=127.0.0.1\nserver.ssl.enabled=true\n\n# The name is set by SelfCertificateConfiguration\nserver.ssl.key-store=file:dummy.pfx\nserver.ssl.key-store-type=PKCS12\n# Password is unimportant, this is a self certificate\nserver.ssl.key-store-password=topsecretstuff\nserver.ssl.key-alias=xeres\n\n## UI options\nxrs.ui.client.colored-emojis=true\nxrs.ui.client.rs-emojis-aliases=true\n# Image cache size (in KB)\nxrs.ui.client.image-cache-size=16384\n\n## Database\n# Cache size (in KB)\nxrs.db.cache-size=16384\n# Maximum compact time on shutdown (in ms)\nxrs.db.max-compact-time=1000\n\n## Network\n# Use the new packet slicing system (not implemented yet, receiving always works)\nxrs.network.packet-slicing=false\n# Use the new packet grouping mechanism (not implemented yet, receiving always works)\nxrs.network.packet-grouping=false\n\n## RsServices\nxrs.service.rtt.enabled=true\nxrs.service.sliceprobe.enabled=true\nxrs.service.serviceinfo.enabled=true\nxrs.service.discovery.enabled=true\nxrs.service.heartbeat.enabled=true\nxrs.service.chat.enabled=true\nxrs.service.status.enabled=true\nxrs.service.identity.enabled=true\nxrs.service.turtle.enabled=true\nxrs.service.forum.enabled=true\nxrs.service.filetransfer.enabled=true\nxrs.service.gxstunnel.enabled=true\nxrs.service.bandwidth.enabled=true\nxrs.service.voip.enabled=true\nxrs.service.board.enabled=true\nxrs.service.channel.enabled=true\n\n## Swagger UI\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# Temporarily remove tomcat's thread warning output, see https://github.com/zapek/Xeres/issues/64\nlogging.level.org.apache.catalina.loader=ERROR\n\n# Remove the ExceptionWebSocketHandlerDecorator which complains on shutdown\nlogging.level.org.springframework.web.socket.handler.ExceptionWebSocketHandlerDecorator=ERROR\n\n# Graceful shutdown. This is useful as it will put a warning if there are still active connections\nserver.shutdown=graceful\nspring.lifecycle.timeout-per-shutdown-phase=10s\n\nspring.threads.virtual.enabled=true\n\n# Make it work in IntelliJ CE, VSCode and Windows Terminal\nspring.output.ansi.enabled=always\n\n# Allow uploading bigger files\nspring.servlet.multipart.max-file-size=10MB\n\n# The log format for file logs (more compact)\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %-5p - [%15.15t|%15.15c]: %m%n%wEx\n\n# Enable Problem support (RFC 7807)\nspring.mvc.problemdetails.enabled=true\n\nspring.jmx.enabled=true\nmanagement.endpoints.jmx.exposure.include=NetworkProperties\n\n# We don't want that because it would make a client in a different locale than the server fail\nspring.jackson.datatype.enum.read-enums-using-to-string=false\nspring.jackson.datatype.enum.write-enums-using-to-string=false\n# See for example CreateForumMessageRequest where parentId and originalId are optional\nspring.jackson.deserialization.fail-on-null-for-primitives=false"
  },
  {
    "path": "app/src/main/resources/banner.txt",
    "content": "${AnsiColor.GREEN}__   __${AnsiColor.DEFAULT}\n${AnsiColor.GREEN}\\ \\ / /${AnsiColor.DEFAULT}\n${AnsiColor.GREEN} \\ V /${AnsiColor.DEFAULT}  ___ _ __ ___  ___\n${AnsiColor.GREEN} /   \\ ${AnsiColor.DEFAULT}/ _ \\ '__/ _ \\/ __|\n${AnsiColor.GREEN}/ /^\\ \\ ${AnsiColor.DEFAULT} __/ | |  __/\\__ \\\n${AnsiColor.GREEN}\\/   \\/${AnsiColor.DEFAULT}\\___|_|  \\___||___/\n:: ${AnsiColor.YELLOW}Uncensorable Friend-to-Friend${AnsiColor.DEFAULT} ::\n:: https://xeres.io ::\n"
  },
  {
    "path": "app/src/main/resources/bdboot.txt",
    "content": "87.98.162.88 6881\n67.215.246.10 6881\n185.157.221.247 25401\n85.195.217.254 25402\n37.187.117.123 25402"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_10_202407122208__AlterFileDownloadCompleted.sql",
    "content": "--\n-- Allow to mark file downloads as completed\n--\nALTER TABLE file_download ADD COLUMN completed BOOLEAN NOT NULL DEFAULT FALSE AFTER chunk_map;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_11_202408021538__AddEncryptedHashes.sql",
    "content": "--\n-- Add encrypted hashes to files\n--\nALTER TABLE file ADD COLUMN encrypted_hash BINARY(20) AFTER hash;"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_12_202408021849__AddEncryptedHashIndex.sql",
    "content": "--\n-- Add index for encrypted hashes\n--\nCREATE INDEX idx_encrypted_hash ON file(encrypted_hash);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_13_202408121618__AddLocationToFileDownload.sql",
    "content": "--\n-- Add location to file downloads\n--\nALTER TABLE file_download ADD COLUMN location_id BIGINT DEFAULT NULL AFTER size;"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_14_202408221303__AddAvailabilityToLocation.sql",
    "content": "--\n-- Add availability field to locations\n--\nALTER TABLE location ADD COLUMN availability ENUM ('available', 'busy', 'away') DEFAULT 'available' AFTER net_mode;"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_15_202409220053__AddChatBacklog.sql",
    "content": "--\n-- Add chat backlog\n--\nCREATE TABLE chat_backlog\n(\n\tid              BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tlocation_id     BIGINT NOT NULL,\n\tcreated         TIMESTAMP(9) NOT NULL,\n\town             BOOLEAN NOT NULL,\n\tmessage         VARCHAR(199000)\n);\nCREATE INDEX idx_location_created ON chat_backlog (location_id, created);\n\nCREATE TABLE chat_room_backlog\n(\n\tid              BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\troom_id         BIGINT NOT NULL,\n\tcreated         TIMESTAMP(9) NOT NULL,\n\tgxs_id          BINARY(16) DEFAULT NULL,\n\tnickname        VARCHAR(512) NOT NULL,\n\tmessage         VARCHAR(199000)\n);\nCREATE INDEX idx_room_created ON chat_room_backlog (room_id, created);\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_16_202410061715__AddProfileValidation.sql",
    "content": "--\n-- Add profile validation to identities\n--\nALTER TABLE identity_group ADD COLUMN profile_id BIGINT DEFAULT NULL AFTER id;\nALTER TABLE identity_group ADD COLUMN next_validation TIMESTAMP(9) DEFAULT NULL AFTER profile_signature;\n\nUPDATE identity_group SET next_validation = PARSEDATETIME('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') WHERE id != 1 AND profile_signature IS NOT NULL"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_17_202410112205__AddProfileCreation.sql",
    "content": "--\n-- Add profile creation time\n--\nALTER TABLE profile ADD COLUMN created TIMESTAMP(9) DEFAULT NULL AFTER pgp_identifier;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_18_202410201950__AddLocationVersion.sql",
    "content": "--\n-- Add location version\n--\nALTER TABLE location ADD COLUMN version VARCHAR(64) DEFAULT NULL AFTER last_connected;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_19_202411171309__AddExtendedFingerprint.sql",
    "content": "--\n-- Extend fingerprints to 32 bytes\n--\nALTER TABLE profile ALTER COLUMN pgp_fingerprint VARBINARY(32);\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_1_202001232214__InitDb.sql",
    "content": "--\n-- Database creation\n--\n-- Avoid touching this file if unnecessary (even comments) as this will trigger\n-- a flyway migration. Migrations will be consistently used and this file won't\n-- be touched anymore when we reach 1.0.0\n--\n-- See https://h2database.com/html/datatypes.html for the data types\n--\nCREATE TABLE profile\n(\n\tid                  BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tname                VARCHAR(64) NOT NULL,\n\tpgp_identifier      BIGINT      NOT NULL UNIQUE,\n\tpgp_fingerprint     BINARY(20)  NOT NULL,\n\tpgp_public_key_data VARBINARY(16384),\n\taccepted            BOOLEAN     NOT NULL                                      DEFAULT false,\n\ttrust               ENUM ('unknown', 'never', 'marginal', 'full', 'ultimate') DEFAULT 'unknown'\n);\n\nCREATE TABLE location\n(\n\tid                  BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tprofile_id          BIGINT      NOT NULL,\n\tname                VARCHAR(64) NOT NULL,\n\tlocation_identifier BINARY(16)  NOT NULL UNIQUE,\n\tconnected           BOOLEAN     NOT NULL                                            DEFAULT false,\n\tdiscoverable        BOOLEAN     NOT NULL                                            DEFAULT true,\n\tdht                 BOOLEAN     NOT NULL                                            DEFAULT true,\n\tnet_mode            ENUM ('unknown', 'udp', 'upnp', 'ext', 'hidden', 'unreachable') DEFAULT 'unknown',\n\tlast_connected      TIMESTAMP,\n\tCONSTRAINT fk_location_profile FOREIGN KEY (profile_id) REFERENCES profile (id)\n);\n\nCREATE TABLE connection\n(\n\tid             BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tlocation_id    BIGINT       NOT NULL,\n\ttype           ENUM ('invalid', 'ipv4', 'ipv6', 'tor', 'hostname', 'i2p'),\n\taddress        VARCHAR(128) NOT NULL,\n\tlast_connected TIMESTAMP,\n\texternal       BOOLEAN      NOT NULL,\n\tCONSTRAINT fk_connection_location FOREIGN KEY (location_id) REFERENCES location (id)\n);\n\nCREATE TABLE settings\n(\n\tlock                        TINYINT NOT NULL DEFAULT 1,\n\n\tpgp_private_key_data        VARBINARY(16384) DEFAULT NULL,\n\n\tlocation_private_key_data   VARBINARY(16384) DEFAULT NULL,\n\tlocation_public_key_data    VARBINARY(16384) DEFAULT NULL,\n\tlocation_certificate        VARBINARY(16384) DEFAULT NULL,\n\n\tlocal_port                  INT NOT NULL DEFAULT 0,\n\n\ttor_socks_host              VARCHAR(253)     DEFAULT NULL,\n\ttor_socks_port              INT     NOT NULL DEFAULT 0,\n\ti2p_socks_host              VARCHAR(253)     DEFAULT NULL,\n\ti2p_socks_port              INT     NOT NULL DEFAULT 0,\n\n\tupnp_enabled                BOOLEAN NOT NULL DEFAULT TRUE,\n\tbroadcast_discovery_enabled BOOLEAN NOT NULL DEFAULT TRUE,\n\tdht_enabled                 BOOLEAN NOT NULL DEFAULT TRUE,\n\n\tauto_start_enabled          BOOLEAN NOT NULL DEFAULT FALSE,\n\n\tCONSTRAINT pk_t1 PRIMARY KEY (lock),\n\tCONSTRAINT ck_t1_locked CHECK (lock = 1)\n);\nINSERT INTO settings (lock)\nVALUES (1);\n\nCREATE TABLE gxs_client_update\n(\n\tid           BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tlocation_id  BIGINT NOT NULL,\n\tservice_type INT    NOT NULL,\n\tlast_synced  TIMESTAMP,\n\tCONSTRAINT fk_gxs_client_update_location FOREIGN KEY (location_id) REFERENCES location (id)\n);\nCREATE INDEX idx_location_service ON gxs_client_update (location_id, service_type);\n\nCREATE TABLE gxs_client_update_messages\n(\n\tgxs_client_update_id BIGINT     NOT NULL,\n\tidentifier           BINARY(16) NOT NULL, -- normal name would be 'gxs_id' but hibernate doesn't let us use @AttributeOverride for an embeddable key and basic type (it wants the value as an embeddable type too then)\n\tupdated              TIMESTAMP  NOT NULL\n);\n\nCREATE TABLE gxs_service_setting\n(\n\tid           INT PRIMARY KEY NOT NULL,\n\tlast_updated TIMESTAMP\n);\n\nCREATE TABLE chat_room\n(\n\tid                BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\troom_id           BIGINT       NOT NULL,\n\tidentity_group_id BIGINT       NOT NULL,\n\tname              VARCHAR(256) NOT NULL,\n\ttopic             VARCHAR(256) NOT NULL,\n\tflags             INT          NOT NULL DEFAULT 0,\n\tsubscribed        BOOLEAN      NOT NULL DEFAULT true,\n\tjoined            BOOLEAN      NOT NULL DEFAULT false\n);\n\nCREATE TABLE gxs_group\n(\n\tid                    BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tgxs_id                BINARY(16)   NOT NULL UNIQUE,\n\toriginal_gxs_id       BINARY(16),\n\tname                  VARCHAR(512) NOT NULL,\n\tdiffusion_flags       INT          NOT NULL                                                                                   DEFAULT 0,\n\tsignature_flags       INT          NOT NULL                                                                                   DEFAULT 0,\n\tpublished             TIMESTAMP,\n\tauthor                BINARY(16),\n\tcircle_id             BINARY(16),\n\tcircle_type           ENUM ('unknown', 'public', 'external', 'your_friends_only', 'local', 'external_self', 'your_eyes_only') DEFAULT 'unknown',\n\tauthentication_flags  INT          NOT NULL                                                                                   DEFAULT 0,\n\tparent_id             BINARY(16),\n\tpopularity            INT          NOT NULL                                                                                   DEFAULT 0,\n\tvisible_message_count INT          NOT NULL                                                                                   DEFAULT 0,\n\tlast_posted           TIMESTAMP,\n\tstatus                INT          NOT NULL                                                                                   DEFAULT 0,\n\tservice_string        VARCHAR(512),\n\toriginator            BINARY(16),\n\tinternal_circle       BINARY(16),\n\tsubscribed BOOLEAN NOT NULL DEFAULT FALSE\n);\n\nCREATE TABLE gxs_group_private_keys\n(\n\tgxs_group_id       BIGINT     NOT NULL,\n\tkey_id             BINARY(16) NOT NULL,\n\tflags              INT        NOT NULL DEFAULT 0,\n\tvalid_from         TIMESTAMP  NOT NULL,\n\tvalid_to           TIMESTAMP,\n\tdata               VARBINARY(16384)\n);\n\nCREATE TABLE gxs_group_public_keys\n(\n\tgxs_group_id      BIGINT     NOT NULL,\n\tkey_id            BINARY(16) NOT NULL,\n\tflags             INT        NOT NULL DEFAULT 0,\n\tvalid_from        TIMESTAMP  NOT NULL,\n\tvalid_to          TIMESTAMP,\n\tdata              VARBINARY(16384)\n);\n\nCREATE TABLE gxs_group_signatures\n(\n\tgxs_group_id     BIGINT     NOT NULL,\n\ttype             ENUM ('author', 'publish', 'admin'),\n\tgxs_id           BINARY(16) NOT NULL,\n\tdata             VARBINARY(512)\n);\n\nCREATE TABLE identity_group\n(\n\tid                BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tprofile_hash      BINARY(20),\n\tprofile_signature VARBINARY(2048),\n\timage             VARBINARY(131072)                         DEFAULT NULL,\n\ttype              ENUM ('other', 'own', 'friend', 'banned') DEFAULT 'other'\n);\nCREATE INDEX idx_type ON identity_group (type);\n\nCREATE TABLE forum_group\n(\n\tid          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tdescription VARCHAR(4096)\n);\n\nCREATE TABLE forum_group_admins\n(\n\tforum_group_id BIGINT NOT NULL,\n\tadmin          BINARY(16)\n);\n\nCREATE TABLE forum_group_pinned_posts\n(\n\tforum_group_id BIGINT NOT NULL,\n\tpinned_post    BINARY(20)\n);\n\nCREATE TABLE gxs_message\n(\n\tid                  BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tgxs_id              BINARY(16)   NOT NULL,\n\tmessage_id          BINARY(20)   NOT NULL,\n\tthread_id           BINARY(20),\n\tparent_id           BINARY(20),\n\toriginal_message_id BINARY(20),\n\tauthor_id           BINARY(16),\n\tname                VARCHAR(512) NOT NULL,\n\tpublished           TIMESTAMP,\n\tflags               INT          NOT NULL DEFAULT 0,\n\tstatus              INT          NOT NULL DEFAULT 0,\n\tchild               TIMESTAMP,\n\tservice_string      VARCHAR(512)\n);\nCREATE INDEX idx_gxs_id ON gxs_message (gxs_id);\nCREATE INDEX idx_message_id ON gxs_message (message_id);\n\nCREATE TABLE gxs_message_signatures\n(\n\tgxs_message_id   BIGINT     NOT NULL,\n\ttype             ENUM ('author', 'publish', 'admin'),\n\tgxs_id           BINARY(16) NOT NULL,\n\tsignatures_order INT        NOT NULL DEFAULT 0,\n\tdata             VARBINARY(512)\n);\n\nCREATE TABLE forum_message\n(\n\tid      BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tcontent VARCHAR(199000),\n\tread    BOOLEAN NOT NULL DEFAULT FALSE\n);\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_20_202411212150__AlterShareLastScanned.sql",
    "content": "--\n-- Make sure we don't have null values for last scanned because the criteria API doesn't support nullsFirst\n--\nUPDATE share SET last_scanned = parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') WHERE last_scanned IS NULL;\n\nALTER TABLE share ALTER COLUMN last_scanned TIMESTAMP(9) NOT NULL DEFAULT parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss');\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_21_202412142109__AddRemoteOptions.sql",
    "content": "--\n-- Add remote options\n--\nALTER TABLE settings ADD COLUMN remote_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER remote_password;\nALTER TABLE settings ADD COLUMN upnp_remote_enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER remote_enabled;"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_22_202412211327__AddRemotePort.sql",
    "content": "--\n-- Add remote port\n--\nALTER TABLE settings ADD COLUMN remote_port INTEGER NOT NULL DEFAULT 0 AFTER remote_enabled;"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_23_202412242306__AddChatRoomLocations.sql",
    "content": "--\n-- Add participating locations to chat rooms\n--\nCREATE TABLE chat_room_locations\n(\n\tchat_room_id BIGINT NOT NULL,\n\tlocations_id  BIGINT NOT NULL\n);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_24_202502252128__AddDistantChatBacklog.sql",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n--\n-- Add distant chat backlog\n--\nCREATE TABLE distant_chat_backlog\n(\n\tid              BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tidentity_id     BIGINT NOT NULL,\n\tcreated         TIMESTAMP(9) NOT NULL,\n\town             BOOLEAN NOT NULL,\n\tmessage         VARCHAR(199000)\n);\nCREATE INDEX idx_identity_created ON distant_chat_backlog (identity_id, created);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_25_202504051643__AcceptNullNamedLocations.sql",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n--\n-- Accept locations with null names (means the name will be updated by discovery)\n--\n\nALTER TABLE location ALTER COLUMN name SET NULL;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_26_202504152033__AdjustBacklogMessageSizes.sql",
    "content": "--\n-- Adjust message backlog sizes\n--\n\nALTER TABLE chat_backlog ALTER COLUMN message VARCHAR(300000);\nALTER TABLE distant_chat_backlog ALTER COLUMN message VARCHAR(300000);\nALTER TABLE chat_room_backlog ALTER COLUMN message VARCHAR(40000);\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_27_202511240013__AddBoards.sql",
    "content": "--\n-- Add Boards\n--\nCREATE TABLE board_group\n(\n\tid          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tdescription VARCHAR(4096),\n\timage       VARBINARY(199000) DEFAULT NULL\n);\n\nCREATE TABLE board_message\n(\n\tid      BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tcontent VARCHAR(8192),\n\timage   VARBINARY(199000) DEFAULT NULL,\n\tlink    VARCHAR(2048),\n\tread    BOOLEAN NOT NULL DEFAULT FALSE\n);\n\nCREATE TABLE comment_message\n(\n\tid      BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tcomment VARCHAR(4096)\n);\n\nCREATE TABLE vote_message\n(\n\tid      BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\ttype    ENUM ('none', 'down', 'up')\n);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_28_202511281815__AddChannels.sql",
    "content": "--\n-- Add Channels\n--\nCREATE TABLE channel_group\n(\n\tid          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tdescription VARCHAR(4096),\n\timage       VARBINARY(199000) DEFAULT NULL\n);\n\nCREATE TABLE channel_message\n(\n\tid      BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tcontent VARCHAR(8192),\n\ttitle   VARCHAR(1024),\n\tcomment VARCHAR(8192),\n\timage   VARBINARY(199000) DEFAULT NULL,\n\tread    BOOLEAN NOT NULL DEFAULT FALSE\n);\n\nCREATE TABLE channel_message_files\n(\n\tchannel_message_id BIGINT NOT NULL,\n\tsize               BIGINT NOT NULL,\n\thash               BINARY(20) NOT NULL,\n\tname               VARCHAR(1024),\n\tpath               VARCHAR(2048),\n\tage                INT NOT NULL\n);\nCREATE INDEX idx_channel_message_id ON channel_message_files (channel_message_id);\n\n-- Add indexes to speed up support linked tables\nCREATE INDEX idx_gxs_client_update_messages_gxs_client_update_id ON gxs_client_update_messages (gxs_client_update_id);\nCREATE INDEX idx_gxs_group_private_keys_gxs_group_id ON gxs_group_private_keys (gxs_group_id);\nCREATE INDEX idx_gxs_group_public_keys_gxs_group_id ON gxs_group_public_keys (gxs_group_id);\nCREATE INDEX idx_gxs_group_signatures_gxs_group_id ON gxs_group_signatures (gxs_group_id);\nCREATE INDEX idx_gxs_message_signatures_gxs_message_id ON gxs_message_signatures (gxs_message_id);\nCREATE INDEX idx_forum_group_admins_forum_group_id ON forum_group_admins (forum_group_id);\nCREATE INDEX idx_forum_group_pinned_posts_forum_group_id ON forum_group_pinned_posts (forum_group_id);\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_29_202512212323__FixGxsSizeLimits.sql",
    "content": "--\n-- Change the GxS limits to make sure they fit.\n-- It's 199000 per message, but we never know which field will\n-- have that limit.\n--\nALTER TABLE forum_group ALTER COLUMN description VARCHAR(199000);\n\nALTER TABLE channel_group ALTER COLUMN description VARCHAR(199000);\nALTER TABLE channel_message ALTER COLUMN content VARCHAR(199000);\nALTER TABLE channel_message ALTER COLUMN title VARCHAR(199000);\nALTER TABLE channel_message ALTER COLUMN comment VARCHAR(199000);\n\nALTER TABLE board_group ALTER COLUMN description VARCHAR(199000);\nALTER TABLE board_message ALTER COLUMN content VARCHAR(199000);\nALTER TABLE board_message ALTER COLUMN link VARCHAR(199000);\n\nALTER TABLE comment_message ALTER COLUMN comment VARCHAR(199000);\n\nALTER TABLE board_message ADD COLUMN image_width INT NOT NULL DEFAULT 0 AFTER image;\nALTER TABLE board_message ADD COLUMN image_height INT NOT NULL DEFAULT 0 AFTER image;\n\nALTER TABLE channel_message ADD COLUMN image_width INT NOT NULL DEFAULT 0 AFTER image;\nALTER TABLE channel_message ADD COLUMN image_height INT NOT NULL DEFAULT 0 AFTER image;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_2_202312151830__AddIncomingDirectory.sql",
    "content": "--\n-- Add incoming directory to settings\n--\nALTER TABLE settings ADD COLUMN incoming_directory VARCHAR(1024) DEFAULT NULL AFTER auto_start_enabled;"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_30_202602161830__ImproveGxsGroupsAndMessage.sql",
    "content": "--\n-- Remove the useless fields in GxsGroupItem and GxsMessageItem.\n-- Add a field to handle multi versioning.\n-- Fix identity service string.\n-- Speed up forum, board and channel counting\n--\nALTER TABLE gxs_group DROP COLUMN status, service_string, original_gxs_id;\n\nALTER TABLE gxs_message DROP COLUMN status, child, service_string;\n\nALTER TABLE gxs_message ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE AFTER flags;\n\nCREATE INDEX idx_message_hidden ON gxs_message (hidden);\n\nALTER TABLE identity_group ADD COLUMN overall_score INT NOT NULL DEFAULT 5 AFTER type;\nALTER TABLE identity_group ADD COLUMN identity_score INT NOT NULL DEFAULT 5 AFTER overall_score;\nALTER TABLE identity_group ADD COLUMN own_opinion INT NOT NULL DEFAULT 0 AFTER identity_score;\nALTER TABLE identity_group ADD COLUMN peer_opinion INT NOT NULL DEFAULT 0 AFTER own_opinion;\n\nALTER TABLE identity_group ADD COLUMN validation_attempt INT NOT NULL DEFAULT 0 AFTER peer_opinion;\nALTER TABLE identity_group ADD COLUMN last_validation TIMESTAMP(9) AFTER validation_attempt;\n\nALTER TABLE identity_group ADD COLUMN last_usage TIMESTAMP(9) AFTER last_validation;\n\nCREATE INDEX idx_forum_message_read ON forum_message (read);\nCREATE INDEX idx_board_message_read ON board_message (read);\nCREATE INDEX idx_channel_message_read ON channel_message (read);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_31_202602121929__AddLastActivity.sql",
    "content": "--\n-- Add last activity column to know\n-- when remote groups were updated by friends.\n--\nALTER TABLE gxs_group ALTER COLUMN last_posted RENAME TO last_updated;\nALTER TABLE gxs_group ADD COLUMN last_activity TIMESTAMP(9) NOT NULL DEFAULT parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') AFTER visible_message_count;\nALTER TABLE gxs_group ADD COLUMN last_statistics TIMESTAMP(9) NOT NULL DEFAULT parsedatetime('1970-01-01 00:00:00', 'yyyy-MM-dd hh:mm:ss') AFTER last_activity;\n\nCREATE INDEX idx_gxs_group_last_statistics ON gxs_group (last_statistics);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_32_202603092327__AddIndices.sql",
    "content": "--\n-- Indices to speed up lookup\n--\n\nCREATE INDEX idx_message_published ON gxs_message (published);\n\nCREATE INDEX idx_location_last_connected ON location (last_connected);\n\nCREATE INDEX idx_group_last_statistics ON gxs_group (last_statistics);\n\nCREATE INDEX idx_identity_next_validation ON identity_group (next_validation);\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_33_202604260021__FixVotes.sql",
    "content": "--\n-- Migrate vote data because of missing converter\n--\n\nUPDATE vote_message SET type = 'up' WHERE type = 'down';\nUPDATE vote_message SET type = 'down' WHERE type = 'none';\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_3_202401151840__AddSharesAndFiles.sql",
    "content": "--\n-- Add shares and files\n--\nCREATE TABLE file\n(\n\tid        BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tparent_id BIGINT DEFAULT NULL,\n\tname      VARCHAR(255) NOT NULL,\n\ttype      ENUM ('any', 'audio', 'archive', 'cdimage', 'document', 'picture', 'program', 'video', 'directory') DEFAULT 'any',\n\thash      BINARY(20),\n\tmodified  TIMESTAMP,\n\n\tCONSTRAINT fk_file_parent FOREIGN KEY (parent_id) REFERENCES file (id)\n);\nCREATE INDEX idx_parent_name ON file (parent_id, name);\nCREATE INDEX idx_hash ON file (hash);\n\nCREATE TABLE share\n(\n\tid           BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tfile_id      BIGINT NOT NULL,\n\tname         VARCHAR(64) NOT NULL UNIQUE,\n\tsearchable   BOOLEAN NOT NULL DEFAULT false,\n\tbrowsable    ENUM ('unknown', 'never', 'marginal', 'full', 'ultimate') DEFAULT 'unknown',\n\tlast_scanned TIMESTAMP,\n\n\tCONSTRAINT fk_share_file FOREIGN KEY (file_id) REFERENCES file (id)\n);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_4_202402211850__AlterTimestampPrecision.sql",
    "content": "--\n-- Change precision from default microseconds to nanoseconds for all timestamps\n-- so that comparison problems don't arise.\n--\nALTER TABLE file ALTER COLUMN modified TIMESTAMP(9);\nALTER TABLE share ALTER COLUMN last_scanned TIMESTAMP(9);\nALTER TABLE location ALTER COLUMN last_connected TIMESTAMP(9);\nALTER TABLE connection ALTER COLUMN last_connected TIMESTAMP(9);\nALTER TABLE gxs_client_update ALTER COLUMN last_synced TIMESTAMP(9);\nALTER TABLE gxs_client_update_messages ALTER COLUMN updated TIMESTAMP(9);\nALTER TABLE gxs_service_setting ALTER COLUMN last_updated TIMESTAMP(9);\nALTER TABLE gxs_group ALTER COLUMN published TIMESTAMP(9);\nALTER TABLE gxs_group ALTER COLUMN last_posted TIMESTAMP(9);\nALTER TABLE gxs_group_private_keys ALTER COLUMN valid_from TIMESTAMP(9);\nALTER TABLE gxs_group_private_keys ALTER COLUMN valid_to TIMESTAMP(9);\nALTER TABLE gxs_group_public_keys ALTER COLUMN valid_from TIMESTAMP(9);\nALTER TABLE gxs_group_public_keys ALTER COLUMN valid_to TIMESTAMP(9);\nALTER TABLE gxs_message ALTER COLUMN published TIMESTAMP(9);\nALTER TABLE gxs_message ALTER COLUMN child TIMESTAMP(9);\n\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_5_202405122038__AddSizeToFiles.sql",
    "content": "--\n-- Add size to files\n--\nALTER TABLE file ADD COLUMN size BIGINT NOT NULL DEFAULT 0 AFTER name;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_6_202405242209__AddNewFileEnumTypes.sql",
    "content": "--\n-- Add new enum types\n--\nALTER TABLE file ALTER COLUMN type ENUM ('any', 'audio', 'archive', 'document', 'picture', 'program', 'video', 'subtitles', 'collection', 'directory') DEFAULT 'any';\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_7_202406181840__AddFileDownload.sql",
    "content": "--\n-- Add file downloads\n--\nCREATE TABLE file_download\n(\n\tid        BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\tname      VARCHAR(255) NOT NULL,\n\thash      BINARY(20) NOT NULL,\n\tsize      BIGINT NOT NULL,\n\tchunk_map VARBINARY(1000000000)\n);"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_8_202406191850__AddRemotePassword.sql",
    "content": "--\n-- Add remote password\n--\nALTER TABLE settings ADD COLUMN remote_password VARCHAR(64) DEFAULT NULL AFTER incoming_directory;\n"
  },
  {
    "path": "app/src/main/resources/db/migration/V00_0_9_202406201855__AddSettingsVersion.sql",
    "content": "--\n-- Add settings version\n--\nALTER TABLE settings ADD COLUMN version INT NOT NULL DEFAULT 0 AFTER lock;"
  },
  {
    "path": "app/src/main/resources/public/index.html",
    "content": "<!--\n  ~ Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>Xeres Web Interface</title>\n</head>\n<body>\nThe Web Interface is not finished yet, for now you can go to:\n<ul>\n    <li><!--suppress HtmlUnknownTarget --><a href=\"/swagger-ui.html\">Swagger UI</a></li>\n</ul>\n</body>\n</html>"
  },
  {
    "path": "app/src/main/resources/public.asc",
    "content": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\n=RMg2\n-----END PGP PUBLIC KEY BLOCK-----\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/ApiTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app;\n\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.chat.ChatRoomVisibility;\nimport io.xeres.common.rest.chat.CreateChatRoomRequest;\nimport io.xeres.common.rest.config.OwnIdentityRequest;\nimport io.xeres.common.rest.config.OwnLocationRequest;\nimport io.xeres.common.rest.config.OwnProfileRequest;\nimport io.xeres.common.rest.forum.CreateForumMessageRequest;\nimport io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest;\nimport io.xeres.common.rest.profile.RsIdRequest;\nimport io.xeres.testutils.ResourceUtils;\nimport io.xeres.ui.support.util.ClientUtils;\nimport org.junit.jupiter.api.*;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.web.reactive.server.WebTestClient;\nimport org.springframework.web.reactive.function.BodyInserters;\n\nimport java.io.IOException;\nimport java.nio.file.FileVisitResult;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.SimpleFileVisitor;\nimport java.nio.file.attribute.BasicFileAttributes;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicLong;\n\nimport static io.xeres.app.ApiTest.DATADIR_PATH;\nimport static io.xeres.common.rest.PathConfig.*;\nimport static org.springframework.boot.test.context.SpringBootTest.UseMainMethod.ALWAYS;\nimport static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;\n\n@SpringBootTest(args = {\"--no-gui\", \"--no-https\", \"--no-control-password\", \"--fast-shutdown\", \"--data-dir=\" + DATADIR_PATH}, useMainMethod = ALWAYS, webEnvironment = RANDOM_PORT) // Do not add --server-only, or it'll break PeerConnectionJob\n@AutoConfigureWebTestClient\n@TestMethodOrder(MethodOrderer.OrderAnnotation.class)\nclass ApiTest\n{\n\tstatic final String DATADIR_PATH = \"./data-apitest\";\n\n\tprivate static final String PROFILE_NAME = \"foobar\";\n\tprivate static final String LOCATION_NAME = \"earth\";\n\tprivate static final String IDENTITY_NAME = \"foobar\";\n\n\t@LocalServerPort\n\tprivate int port;\n\n\t@Autowired\n\tprivate WebTestClient webTestClient;\n\n\t// We need to clean on startup and cleanup because it's tricky to do it on\n\t// shutdown (some files are used still, like the memory mapped bloom filter, and it doesn't\n\t// seem possible to close it without some nasty hacks)\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tdeleteApiDir();\n\t}\n\n\t@AfterAll\n\tstatic void cleanup()\n\t{\n\t\tdeleteApiDir();\n\t}\n\n\t@Test\n\t@Order(1)\n\tvoid createOwnProfile()\n\t{\n\t\tvar profileRequest = new OwnProfileRequest(PROFILE_NAME);\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(CONFIG_PATH + \"/profile\")\n\t\t\t\t.bodyValue(profileRequest)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectHeader().location(getServerUri() + PROFILES_PATH + \"/1\")\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(2)\n\tvoid createOwnLocation()\n\t{\n\t\tvar locationRequest = new OwnLocationRequest(LOCATION_NAME);\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(CONFIG_PATH + \"/location\")\n\t\t\t\t.bodyValue(locationRequest)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectHeader().location(getServerUri() + LOCATIONS_PATH + \"/1\")\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(3)\n\tvoid createOwnIdentity()\n\t{\n\t\tvar identityRequest = new OwnIdentityRequest(IDENTITY_NAME, false);\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(CONFIG_PATH + \"/identity\")\n\t\t\t\t.bodyValue(identityRequest)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectHeader().location(getServerUri() + IDENTITIES_PATH + \"/1\")\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(4)\n\tvoid importFriend()\n\t{\n\t\tvar rsId = \"ABBzjqGSBk4/IOdmQ4zJMFvVAQdOZW1lc2lzAxQG1LRG0gnnUvpxGjl5KyDKZX4nBpENBNJmb28uYmFyLmNvbZIGAwIBVQTSkwYyAajABNICFGlwdjQ6Ly84NS4xLjIuNDoxMjM0BAOiD+U=\";\n\n\t\tvar rsIdRequest = new RsIdRequest(rsId);\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(PROFILES_PATH)\n\t\t\t\t.bodyValue(rsIdRequest)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectHeader().location(getServerUri() + PROFILES_PATH + \"/2\")\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(5)\n\tvoid checkFriend()\n\t{\n\t\twebTestClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(PROFILES_PATH)\n\t\t\t\t\t\t.queryParam(\"name\", \"Nemesis\")\n\t\t\t\t\t\t.build())\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isOk()\n\t\t\t\t.expectHeader().contentType(MediaType.APPLICATION_JSON)\n\t\t\t\t.expectBody()\n\t\t\t\t.jsonPath(\"$.[0].name\").isEqualTo(\"Nemesis\");\n\t}\n\n\t@Test\n\t@Order(6)\n\tvoid changeAvailability()\n\t{\n\t\twebTestClient.put()\n\t\t\t\t.uri(CONFIG_PATH + \"/location/availability\")\n\t\t\t\t.bodyValue(Availability.BUSY)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isOk()\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(7)\n\tvoid getSettings()\n\t{\n\t\twebTestClient.get()\n\t\t\t\t.uri(SETTINGS_PATH)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isOk();\n\t}\n\n\t@Test\n\t@Order(8)\n\tvoid createForum()\n\t{\n\t\tvar request = new CreateOrUpdateForumGroupRequest(\"Test\", \"Just some test forum\");\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(FORUMS_PATH + \"/groups\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(9)\n\tvoid checkForumsAndMessages()\n\t{\n\t\t// Check if the forum was created\n\t\tvar forumGroupId = new AtomicLong();\n\n\t\twebTestClient.get()\n\t\t\t\t.uri(FORUMS_PATH + \"/groups\")\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isOk()\n\t\t\t\t.expectBody()\n\t\t\t\t.jsonPath(\"$.[0].name\").isEqualTo(\"Test\")\n\t\t\t\t.jsonPath(\"$.[0].description\").isEqualTo(\"Just some test forum\")\n\t\t\t\t.jsonPath(\"$.[0].id\").value(o -> forumGroupId.set((Integer) o));\n\n\t\t// Create a message\n\t\tvar createForumMessageRequest = new CreateForumMessageRequest(forumGroupId.get(), \"First message\", \"This is the first message ever\", 0L, 0L);\n\n\t\tvar forumMessageLocation = webTestClient.post()\n\t\t\t\t.uri(FORUMS_PATH + \"/messages\")\n\t\t\t\t.bodyValue(createForumMessageRequest)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectBody().isEmpty()\n\t\t\t\t.getResponseHeaders().getLocation();\n\n\t\tvar path = forumMessageLocation.getPath();\n\n\t\tvar forumMessageId = new AtomicLong();\n\n\t\t// Check the message\n\t\twebTestClient.get()\n\t\t\t\t.uri(FORUMS_PATH + \"/messages/\" + path.substring(path.lastIndexOf('/') + 1))\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isOk()\n\t\t\t\t.expectBody()\n\t\t\t\t.jsonPath(\"$.name\").isEqualTo(\"First message\")\n\t\t\t\t.jsonPath(\"$.content\").isEqualTo(\"This is the first message ever\")\n\t\t\t\t.jsonPath(\"$.id\").value(o -> forumMessageId.set((Integer) o));\n\n\t\t// Edit the message (by posting a new one with original id set to the old one)\n\t\tvar createForEditMessageRequest = new CreateForumMessageRequest(forumGroupId.get(), \"First message (edited)\", \"This is the first message ever (edited)\", 0L, forumMessageId.get());\n\n\t\tvar editedMessageLocation = webTestClient.post()\n\t\t\t\t.uri(FORUMS_PATH + \"/messages\")\n\t\t\t\t.bodyValue(createForEditMessageRequest)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectBody().isEmpty()\n\t\t\t\t.getResponseHeaders().getLocation();\n\n\t\tassert editedMessageLocation != null;\n\t\tpath = editedMessageLocation.getPath();\n\n\t\t// Check edited message\n\t\twebTestClient.get()\n\t\t\t\t.uri(FORUMS_PATH + \"/messages/\" + path.substring(path.lastIndexOf('/') + 1))\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isOk()\n\t\t\t\t.expectBody()\n\t\t\t\t.jsonPath(\"$.name\").isEqualTo(\"First message (edited)\")\n\t\t\t\t.jsonPath(\"$.content\").isEqualTo(\"This is the first message ever (edited)\");\n\t}\n\n\t@Test\n\t@Order(10)\n\tvoid createChatRoom()\n\t{\n\t\tvar request = new CreateChatRoomRequest(\"Test\", \"Anything, really\", ChatRoomVisibility.PUBLIC, true);\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(CHAT_PATH + \"/rooms\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(11)\n\tvoid createBoard()\n\t{\n\t\tvar builder = ClientUtils.createGroupBuilder(\"Test\", \"A cool board\", ResourceUtils.getResourceAsFile(\"/image/abitbol.png\"));\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(BOARDS_PATH + \"/groups\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\t@Test\n\t@Order(12)\n\tvoid createChannel()\n\t{\n\t\tvar builder = ClientUtils.createGroupBuilder(\"Test\", \"A cool channel\", ResourceUtils.getResourceAsFile(\"/image/leguman.jpg\"));\n\n\t\twebTestClient.post()\n\t\t\t\t.uri(CHANNELS_PATH + \"/groups\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.exchange()\n\t\t\t\t.expectStatus().isCreated()\n\t\t\t\t.expectBody().isEmpty();\n\t}\n\n\tprivate String getServerUri()\n\t{\n\t\treturn \"http://localhost:\" + port;\n\t}\n\n\tprivate static void deleteApiDir()\n\t{\n\t\tRuntime.getRuntime().addShutdownHook(new Thread(() -> {\n\t\t\ttry\n\t\t\t{\n\t\t\t\tdeleteRecursively(Path.of(DATADIR_PATH));\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\t// Don't throw an exception because it's expected this might fail (file still in use, mostly)\n\t\t\t\tSystem.out.println(e.getMessage());\n\t\t\t}\n\t\t}));\n\t}\n\n\tprivate static void deleteRecursively(Path path) throws IOException\n\t{\n\t\tfinal List<IOException> exceptions = new ArrayList<>();\n\t\tFiles.walkFileTree(path, new SimpleFileVisitor<>()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic FileVisitResult visitFile(Path file, BasicFileAttributes attrs)\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tFiles.deleteIfExists(file);\n\t\t\t\t}\n\t\t\t\tcatch (IOException e)\n\t\t\t\t{\n\t\t\t\t\texceptions.add(e);\n\t\t\t\t}\n\t\t\t\treturn FileVisitResult.CONTINUE;\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException\n\t\t\t{\n\t\t\t\tif (exc != null)\n\t\t\t\t{\n\t\t\t\t\tthrow exc;\n\t\t\t\t}\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tFiles.delete(dir);\n\t\t\t\t}\n\t\t\t\tcatch (IOException e)\n\t\t\t\t{\n\t\t\t\t\texceptions.add(e);\n\t\t\t\t}\n\t\t\t\treturn FileVisitResult.CONTINUE;\n\t\t\t}\n\t\t});\n\n\t\t// If any exceptions occurred, throw a combined exception\n\t\tif (!exceptions.isEmpty())\n\t\t{\n\t\t\tvar wrapper = new IOException(\"Errors recursively deleting \" + path);\n\t\t\tfor (IOException exception : exceptions)\n\t\t\t{\n\t\t\t\twrapper.addSuppressed(exception);\n\t\t\t}\n\t\t\tthrow wrapper;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/AppCodingRulesTest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app;\n\nimport com.tngtech.archunit.core.domain.JavaClass;\nimport com.tngtech.archunit.core.domain.JavaModifier;\nimport com.tngtech.archunit.core.importer.ImportOption;\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\nimport com.tngtech.archunit.lang.ArchCondition;\nimport com.tngtech.archunit.lang.ArchRule;\nimport com.tngtech.archunit.lang.ConditionEvents;\nimport com.tngtech.archunit.lang.SimpleConditionEvent;\nimport io.xeres.app.application.environment.CommandArgument;\nimport io.xeres.app.service.UiBridgeService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport jakarta.persistence.Entity;\nimport org.slf4j.Logger;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;\nimport static com.tngtech.archunit.library.GeneralCodingRules.*;\n\n@SuppressWarnings(\"unused\")\n@AnalyzeClasses(packagesOf = XeresApplication.class, importOptions = ImportOption.DoNotIncludeTests.class)\nclass AppCodingRulesTest\n{\n\t@ArchTest\n\tprivate final ArchRule noAccessToStandardStreams = noClasses()\n\t\t\t.should(ACCESS_STANDARD_STREAMS)\n\t\t\t.andShould()\n\t\t\t.notBe(CommandArgument.class)\n\t\t\t.because(\"We use loggers\");\n\n\t@ArchTest\n\tprivate final ArchRule noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;\n\n\t@ArchTest\n\tprivate final ArchRule loggersShouldBeFinalAndStatic =\n\t\t\tfields().that().haveRawType(Logger.class)\n\t\t\t\t\t.should().bePrivate().orShould().beProtected()\n\t\t\t\t\t.andShould().beStatic().orShould().beProtected()\n\t\t\t\t\t.andShould().beFinal()\n\t\t\t\t\t.because(\"we agreed on this convention\");\n\n\t@ArchTest\n\tprivate final ArchRule noFieldInjection = NO_CLASSES_SHOULD_USE_FIELD_INJECTION\n\t\t\t.because(\"Constructor injection allow detection of cyclic dependencies\");\n\n\t@ArchTest\n\tprivate final ArchRule rsServiceNaming = classes()\n\t\t\t.that().areAssignableTo(RsService.class)\n\t\t\t.should().haveSimpleNameEndingWith(\"RsService\");\n\n\t/**\n\t * Items should have a public no-arg constructor and have an empty clone method\n\t * that returns their own type.\n\t */\n\t@ArchTest\n\tprivate final ArchRule rsItem = classes()\n\t\t\t.that().areAssignableTo(Item.class)\n\t\t\t.and().doNotBelongToAnyOf(Item.class)\n\t\t\t.should(new ArchCondition<>(\"have a public constructor without parameters\")\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tpublic void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t{\n\t\t\t\t\tboolean satisfied = javaClass.getConstructors().stream()\n\t\t\t\t\t\t\t.anyMatch(constructor ->\n\t\t\t\t\t\t\t\t\tconstructor.getModifiers().contains(JavaModifier.PUBLIC)\n\t\t\t\t\t\t\t\t\t\t\t&& constructor.getParameters().isEmpty()\n\t\t\t\t\t\t\t);\n\t\t\t\t\tString message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t+ \" a public constructor without parameters\";\n\t\t\t\t\tevents.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t}\n\t\t\t})\n\t\t\t.andShould(new ArchCondition<>(\"have a clone() method that returns their class\")\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tpublic void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t{\n\t\t\t\t\tboolean satisfied = javaClass.getMethods().stream()\n\t\t\t\t\t\t\t.anyMatch(method ->\n\t\t\t\t\t\t\t\t\tmethod.getName().equals(\"clone\")\n\t\t\t\t\t\t\t\t\t\t\t&& method.getParameters().isEmpty()\n\t\t\t\t\t\t\t\t\t\t\t&& method.getReturnType().equals(javaClass)\n\t\t\t\t\t\t\t\t\t\t\t&& method.getModifiers().contains(JavaModifier.PUBLIC));\n\t\t\t\t\tString message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t+ \" a clone() method returning its own type\";\n\t\t\t\t\tevents.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t}\n\t\t\t})\n\t\t\t.andShould(new ArchCondition<>(\"have a toString() method that returns a meaningful description\")\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tpublic void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t{\n\t\t\t\t\tboolean satisfied = javaClass.getMethods().stream()\n\t\t\t\t\t\t\t.anyMatch(method ->\n\t\t\t\t\t\t\t\t\tmethod.getName().equals(\"toString\")\n\t\t\t\t\t\t\t\t\t\t\t&& method.getParameters().isEmpty()\n\t\t\t\t\t\t\t\t\t\t\t&& method.getModifiers().contains(JavaModifier.PUBLIC)\n\t\t\t\t\t\t\t\t\t\t\t&& method.getReturnType().getName().equals(\"java.lang.String\")\n\t\t\t\t\t\t\t\t\t\t\t&& method.getOwner().equals(javaClass)\n\t\t\t\t\t\t\t);\n\t\t\t\t\tString message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t+ \" a toString() method returning a meaningful description\";\n\t\t\t\t\tevents.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t}\n\t\t\t});\n\n\t/**\n\t * JPA entities should have a public or protected no-arg constructor.\n\t */\n\t@ArchTest\n\tprivate final ArchRule jpaEntitiesEmptyConstructor = classes()\n\t\t\t.that().areAnnotatedWith(Entity.class)\n\t\t\t.should(new ArchCondition<>(\"have a public constructor without parameters\")\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tpublic void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t{\n\t\t\t\t\tboolean satisfied = javaClass.getConstructors().stream()\n\t\t\t\t\t\t\t.anyMatch(constructor ->\n\t\t\t\t\t\t\t\t\t(constructor.getModifiers().contains(JavaModifier.PUBLIC) || constructor.getModifiers().contains(JavaModifier.PROTECTED))\n\t\t\t\t\t\t\t\t\t\t\t&& constructor.getParameters().isEmpty()\n\t\t\t\t\t\t\t);\n\t\t\t\t\tString message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t+ \" a public constructor without parameters\";\n\t\t\t\t\tevents.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t}\n\t\t\t});\n\n\t/**\n\t * The following rule helps avoid dependencies from app to the UI. Everything should be done with\n\t * server notifications, message queues and, if strictly necessary, UiBridgeService.\n\t */\n\t@ArchTest\n\tprivate final ArchRule noUiAccess = noClasses()\n\t\t\t.that().resideInAPackage(\"..app..\")\n\t\t\t.and().doNotBelongToAnyOf(XeresApplication.class, UiBridgeService.class)\n\t\t\t.should().accessClassesThat().resideInAPackage(\"..ui..\");\n\n\t@ArchTest\n\tprivate final ArchRule utilityClass = classes()\n\t\t\t.that().haveSimpleNameEndingWith(\"Utils\")\n\t\t\t.should(new ArchCondition<>(\"have a private constructor without parameters\")\n\t\t\t        {\n\t\t\t\t        @Override\n\t\t\t\t        public void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t        {\n\t\t\t\t\t        boolean satisfied = javaClass.getConstructors().stream()\n\t\t\t\t\t\t\t        .anyMatch(constructor ->\n\t\t\t\t\t\t\t\t\t        constructor.getModifiers().contains(JavaModifier.PRIVATE)\n\t\t\t\t\t\t\t\t\t\t\t        && constructor.getParameters().isEmpty()\n\t\t\t\t\t\t\t        );\n\t\t\t\t\t        String message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t        + \" a private constructor without parameters\";\n\t\t\t\t\t        events.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t        }\n\t\t\t        }\n\t\t\t)\n\t\t\t.andShould().haveModifier(JavaModifier.FINAL);\n\n\t@ArchTest\n\tprivate final ArchRule gxsIdFieldNaming =\n\t\t\tfields().that().haveRawType(GxsId.class)\n\t\t\t\t\t.should().haveNameEndingWith(\"GxsId\")\n\t\t\t\t\t.orShould().haveName(\"gxsId\")\n\t\t\t\t\t.because(\"The name could be confused with database IDs\");\n\n\t@ArchTest\n\tprivate final ArchRule msgIdFieldNaming =\n\t\t\tfields().that().haveRawType(MsgId.class)\n\t\t\t\t\t.should().haveNameEndingWith(\"MsgId\")\n\t\t\t\t\t.orShould().haveName(\"msgId\")\n\t\t\t\t\t.because(\"The name could be confused with database IDs\");\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/AbstractControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller;\n\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;\nimport org.springframework.test.web.servlet.request.MockMvcRequestBuilders;\nimport tools.jackson.databind.ObjectMapper;\n\nimport static org.springframework.http.MediaType.APPLICATION_JSON;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;\n\npublic abstract class AbstractControllerTest\n{\n\t@Autowired\n\tprotected ObjectMapper objectMapper;\n\n\t@Autowired\n\tprotected MockMvc mvc;\n\n\tprotected MockHttpServletRequestBuilder getJson(String uri)\n\t{\n\t\treturn get(uri, APPLICATION_JSON);\n\t}\n\n\tprotected MockHttpServletRequestBuilder get(String uri, MediaType mediaType)\n\t{\n\t\treturn MockMvcRequestBuilders.get(uri)\n\t\t\t\t.accept(mediaType);\n\t}\n\n\tprotected MockHttpServletRequestBuilder postJson(String uri, Object body)\n\t{\n\t\tvar json = objectMapper.writeValueAsString(body);\n\t\treturn post(uri)\n\t\t\t\t.contentType(APPLICATION_JSON)\n\t\t\t\t.accept(APPLICATION_JSON)\n\t\t\t\t.content(json);\n\t}\n\n\tprotected MockHttpServletRequestBuilder putJson(String uri, Object body)\n\t{\n\t\tvar json = objectMapper.writeValueAsString(body);\n\t\treturn put(uri)\n\t\t\t\t.contentType(APPLICATION_JSON)\n\t\t\t\t.accept(APPLICATION_JSON)\n\t\t\t\t.content(json);\n\t}\n\n\tprotected MockHttpServletRequestBuilder patchJson(String uri, Object body)\n\t{\n\t\tvar json = objectMapper.writeValueAsString(body);\n\t\treturn patch(uri)\n\t\t\t\t.contentType(\"application/json-patch+json\")\n\t\t\t\t.accept(APPLICATION_JSON)\n\t\t\t\t.content(json);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/PathConfigTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller;\n\nimport io.xeres.common.rest.PathConfig;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass PathConfigTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(PathConfig.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/board/BoardControllerTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.board;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.gxs.BoardGroupItemFakes;\nimport io.xeres.app.database.model.gxs.BoardMessageItemFakes;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.service.BoardMessageService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.board.BoardRsService;\nimport io.xeres.app.xrs.service.board.item.BoardGroupItem;\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.rest.board.UpdateBoardMessageReadRequest;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\n\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.*;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(BoardController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass BoardControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = BOARDS_PATH;\n\n\t@MockitoBean\n\tprivate BoardRsService boardRsService;\n\n\t@MockitoBean\n\tprivate IdentityService identityService;\n\n\t@MockitoBean\n\tprivate BoardMessageService boardMessageService;\n\n\t@MockitoBean\n\tprivate UnHtmlService unHtmlService;\n\n\t@Test\n\tvoid GetBoardGroups_Success() throws Exception\n\t{\n\t\tvar boardGroups = List.of(BoardGroupItemFakes.createBoardGroupItem(), BoardGroupItemFakes.createBoardGroupItem());\n\n\t\twhen(boardRsService.findAllGroups()).thenReturn(boardGroups);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(boardGroups.get(0).getId()), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\", is(boardGroups.get(0).getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.[1].id\").value(is(boardGroups.get(1).getId()), Long.class));\n\n\t\tverify(boardRsService).findAllGroups();\n\t}\n\n\t@Test\n\tvoid CreateBoardGroup_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(boardRsService.createBoardGroup(eq(ownIdentity.getGxsId()), eq(\"foo\"), eq(\"the best\"), any())).thenReturn(1L);\n\n\t\tmvc.perform(multipart(BASE_URL + \"/groups\")\n\t\t\t\t\t\t.param(\"name\", \"foo\")\n\t\t\t\t\t\t.param(\"description\", \"the best\"))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + BOARDS_PATH + \"/groups/\" + 1L));\n\n\t\tverify(boardRsService).createBoardGroup(eq(ownIdentity.getGxsId()), anyString(), anyString(), any());\n\t}\n\n\t@Test\n\tvoid UpdateBoardGroup_Success() throws Exception\n\t{\n\t\tmvc.perform(multipart(HttpMethod.PUT, BASE_URL + \"/groups/1\")\n\t\t\t\t\t\t.param(\"name\", \"foo\")\n\t\t\t\t\t\t.param(\"description\", \"the best\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(boardRsService).updateBoardGroup(1L, \"foo\", \"the best\", null, false);\n\t}\n\n\t@Test\n\tvoid UpdateBoardGroup_WithUpdateImageFlag_Success() throws Exception\n\t{\n\t\tmvc.perform(multipart(HttpMethod.PUT, BASE_URL + \"/groups/1\")\n\t\t\t\t\t\t.param(\"name\", \"foo\")\n\t\t\t\t\t\t.param(\"description\", \"the best\")\n\t\t\t\t\t\t.param(\"updateImage\", \"true\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(boardRsService).updateBoardGroup(1L, \"foo\", \"the best\", null, true);\n\t}\n\n\t@Test\n\tvoid GetBoardByGroupId_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tvar boardGroupItem = new BoardGroupItem(null, \"foobar\");\n\n\t\twhen(boardRsService.findById(groupId)).thenReturn(Optional.of(boardGroupItem));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is(boardGroupItem.getId()), Long.class));\n\t}\n\n\t@Test\n\tvoid UpdateBoardMessageReadFlag_Success() throws Exception\n\t{\n\t\tvar request = new UpdateBoardMessageReadRequest(1L, true);\n\n\t\tmvc.perform(patchJson(BASE_URL + \"/messages\", request))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(boardRsService).setMessageReadState(1L, true);\n\t}\n\n\t@Test\n\tvoid GetBoardUnreadCount_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tint unreadCount = 5;\n\n\t\twhen(boardRsService.getUnreadCount(groupId)).thenReturn(unreadCount);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId + \"/unread-count\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.APPLICATION_JSON))\n\t\t\t\t.andExpect(content().string(String.valueOf(unreadCount)));\n\n\t\tverify(boardRsService).getUnreadCount(groupId);\n\t}\n\n\t@Test\n\tvoid SubscribeToBoardGroup_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/groups/\" + groupId + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(boardRsService).subscribeToBoardGroup(groupId);\n\t}\n\n\t@Test\n\tvoid SetAllGroupMessagesReadState_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/groups/\" + groupId + \"/read?read=true\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(boardRsService).setAllGroupMessagesReadState(groupId, true);\n\t}\n\n\t@Test\n\tvoid UnsubscribeFromBoardGroup_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(delete(BASE_URL + \"/groups/\" + groupId + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(boardRsService).unsubscribeFromBoardGroup(groupId);\n\t}\n\n\t@Test\n\tvoid GetBoardMessages_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tPage<BoardMessageItem> boardMessages = new PageImpl<>(List.of(BoardMessageItemFakes.createBoardMessageItem(), BoardMessageItemFakes.createBoardMessageItem()));\n\n\t\twhen(boardRsService.findAllMessages(eq(groupId), any(Pageable.class))).thenReturn(boardMessages);\n\t\twhen(boardMessageService.getAuthorsMapFromMessages(boardMessages)).thenReturn(Collections.emptyMap());\n\t\twhen(boardMessageService.getMessagesMapFromSummaries(groupId, boardMessages)).thenReturn(Collections.emptyMap());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId + \"/messages\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.content.size()\").value(is(boardMessages.getTotalElements()), Long.class));\n\n\t\tverify(boardRsService).findAllMessages(eq(groupId), any(Pageable.class));\n\t\tverify(boardMessageService).getAuthorsMapFromMessages(boardMessages);\n\t\tverify(boardMessageService).getMessagesMapFromSummaries(groupId, boardMessages);\n\t}\n\n\t@Test\n\tvoid GetBoardMessage_Success() throws Exception\n\t{\n\t\tlong id = 1L;\n\t\tBoardMessageItem boardMessage = BoardMessageItemFakes.createBoardMessageItem();\n\n\t\twhen(boardRsService.findMessageById(id)).thenReturn(Optional.of(boardMessage));\n\t\twhen(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty());\n\t\twhen(boardRsService.findAllMessagesIncludingOlds(any(GxsId.class), anySet())).thenReturn(List.of());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/messages/\" + id))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is((int) boardMessage.getId())));\n\n\t\tverify(boardRsService).findMessageById(id);\n\t\tverify(identityService).findByGxsId(null);\n\t\tverify(boardRsService).findAllMessagesIncludingOlds(any(GxsId.class), anySet());\n\t}\n\n\t@Test\n\tvoid CreateBoardMessage_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(boardRsService.createBoardMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\teq(1L),\n\t\t\t\teq(\"Test Title\"),\n\t\t\t\teq(\"Test Content\"),\n\t\t\t\teq(\"https://zapek.com\"),\n\t\t\t\tany()\n\t\t)).thenReturn(1L);\n\n\t\tmvc.perform(multipart(BASE_URL + \"/messages\")\n\t\t\t\t\t\t.param(\"boardId\", \"1\")\n\t\t\t\t\t\t.param(\"title\", \"Test Title\")\n\t\t\t\t\t\t.param(\"content\", \"Test Content\")\n\t\t\t\t\t\t.param(\"link\", \"https://zapek.com\"))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + BOARDS_PATH + \"/messages/\" + 1L));\n\n\t\tverify(boardRsService).createBoardMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\tanyLong(),\n\t\t\t\tanyString(),\n\t\t\t\tanyString(),\n\t\t\t\tanyString(),\n\t\t\t\tany()\n\t\t);\n\t}\n\n\t@Test\n\tvoid DownloadBoardGroupImage_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tbyte[] pngImage = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d};\n\t\tvar boardGroupItem = new BoardGroupItem(null, \"foobar\");\n\t\tboardGroupItem.setImage(pngImage);\n\n\t\twhen(boardRsService.findById(groupId)).thenReturn(Optional.of(boardGroupItem));\n\n\t\tmvc.perform(get(BOARDS_PATH + \"/groups/\" + groupId + \"/image\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.IMAGE_PNG));\n\t}\n\n\t@Test\n\tvoid DownloadBoardGroupImage_Empty_Returns204() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tvar boardGroupItem = new BoardGroupItem(null, \"foobar\");\n\n\t\twhen(boardRsService.findById(groupId)).thenReturn(Optional.of(boardGroupItem));\n\n\t\tmvc.perform(get(BOARDS_PATH + \"/groups/\" + groupId + \"/image\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isNoContent());\n\t}\n\n\t@Test\n\tvoid DownloadBoardGroupImage_NotFound() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\twhen(boardRsService.findById(groupId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(get(BOARDS_PATH + \"/groups/\" + groupId + \"/image\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid DownloadBoardMessageImage_Success() throws Exception\n\t{\n\t\tlong messageId = 1L;\n\t\tbyte[] jpegImage = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff, (byte) 0xe0, 0x00, 0x00, 0x00, 0x00};\n\t\tvar boardMessageItem = BoardMessageItemFakes.createBoardMessageItem();\n\t\tboardMessageItem.setImage(jpegImage);\n\n\t\twhen(boardRsService.findMessageById(messageId)).thenReturn(Optional.of(boardMessageItem));\n\n\t\tmvc.perform(get(BOARDS_PATH + \"/messages/\" + messageId + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.IMAGE_JPEG));\n\t}\n\n\t@Test\n\tvoid DownloadBoardMessageImage_Empty_Returns204() throws Exception\n\t{\n\t\tlong messageId = 1L;\n\t\tvar boardMessageItem = BoardMessageItemFakes.createBoardMessageItem();\n\n\t\twhen(boardRsService.findMessageById(messageId)).thenReturn(Optional.of(boardMessageItem));\n\n\t\tmvc.perform(get(BOARDS_PATH + \"/messages/\" + messageId + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isNoContent());\n\t}\n\n\t@Test\n\tvoid DownloadBoardMessageImage_NotFound() throws Exception\n\t{\n\t\tlong messageId = 1L;\n\n\t\twhen(boardRsService.findMessageById(messageId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(get(BOARDS_PATH + \"/messages/\" + messageId + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/channel/ChannelControllerTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.channel;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.gxs.ChannelGroupItemFakes;\nimport io.xeres.app.database.model.gxs.ChannelMessageItemFakes;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.service.ChannelMessageService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.channel.ChannelRsService;\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.rest.channel.UpdateChannelMessageReadRequest;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\n\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.*;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(ChannelController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ChannelControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = CHANNELS_PATH;\n\n\t@MockitoBean\n\tprivate ChannelRsService channelRsService;\n\n\t@MockitoBean\n\tprivate IdentityService identityService;\n\n\t@MockitoBean\n\tprivate ChannelMessageService channelMessageService;\n\n\t@MockitoBean\n\tprivate UnHtmlService unHtmlService;\n\n\t@Test\n\tvoid GetChannelGroups_Success() throws Exception\n\t{\n\t\tvar channelGroups = List.of(ChannelGroupItemFakes.createChannelGroupItem(), ChannelGroupItemFakes.createChannelGroupItem());\n\n\t\twhen(channelRsService.findAllGroups()).thenReturn(channelGroups);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(channelGroups.get(0).getId()), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\", is(channelGroups.get(0).getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.[1].id\").value(is(channelGroups.get(1).getId()), Long.class));\n\n\t\tverify(channelRsService).findAllGroups();\n\t}\n\n\t@Test\n\tvoid CreateChannelGroup_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(channelRsService.createChannelGroup(eq(ownIdentity.getGxsId()), eq(\"foo\"), eq(\"the best\"), any())).thenReturn(1L);\n\n\t\tmvc.perform(multipart(BASE_URL + \"/groups\")\n\t\t\t\t\t\t.param(\"name\", \"foo\")\n\t\t\t\t\t\t.param(\"description\", \"the best\"))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + CHANNELS_PATH + \"/groups/\" + 1L));\n\n\t\tverify(channelRsService).createChannelGroup(eq(ownIdentity.getGxsId()), anyString(), anyString(), any());\n\t}\n\n\t@Test\n\tvoid UpdateChannelGroup_Success() throws Exception\n\t{\n\t\tmvc.perform(multipart(HttpMethod.PUT, BASE_URL + \"/groups/1\")\n\t\t\t\t\t\t.param(\"name\", \"foo\")\n\t\t\t\t\t\t.param(\"description\", \"the best\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(channelRsService).updateChannelGroup(1L, \"foo\", \"the best\", null, false);\n\t}\n\n\t@Test\n\tvoid UpdateChannelGroup_WithUpdateImageFlag_Success() throws Exception\n\t{\n\t\tmvc.perform(multipart(HttpMethod.PUT, BASE_URL + \"/groups/1\")\n\t\t\t\t\t\t.param(\"name\", \"foo\")\n\t\t\t\t\t\t.param(\"description\", \"the best\")\n\t\t\t\t\t\t.param(\"updateImage\", \"true\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(channelRsService).updateChannelGroup(1L, \"foo\", \"the best\", null, true);\n\t}\n\n\t@Test\n\tvoid GetChannelGroupById_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tvar channelGroupItem = ChannelGroupItemFakes.createChannelGroupItem();\n\n\t\twhen(channelRsService.findById(groupId)).thenReturn(Optional.of(channelGroupItem));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is(channelGroupItem.getId()), Long.class));\n\t}\n\n\t@Test\n\tvoid UpdateChannelMessageReadFlag_Success() throws Exception\n\t{\n\t\tvar request = new UpdateChannelMessageReadRequest(1L, true);\n\n\t\tmvc.perform(patchJson(BASE_URL + \"/messages\", request))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(channelRsService).setMessageReadState(1L, true);\n\t}\n\n\t@Test\n\tvoid GetChannelUnreadCount_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tint unreadCount = 5;\n\n\t\twhen(channelRsService.getUnreadCount(groupId)).thenReturn(unreadCount);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId + \"/unread-count\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.APPLICATION_JSON))\n\t\t\t\t.andExpect(content().string(String.valueOf(unreadCount)));\n\n\t\tverify(channelRsService).getUnreadCount(groupId);\n\t}\n\n\t@Test\n\tvoid SubscribeToChannelGroup_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/groups/\" + groupId + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(channelRsService).subscribeToChannelGroup(groupId);\n\t}\n\n\t@Test\n\tvoid SetAllGroupMessagesReadState_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/groups/\" + groupId + \"/read?read=true\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(channelRsService).setAllGroupMessagesReadState(groupId, true);\n\t}\n\n\t@Test\n\tvoid UnsubscribeFromChannelGroup_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(delete(BASE_URL + \"/groups/\" + groupId + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(channelRsService).unsubscribeFromChannelGroup(groupId);\n\t}\n\n\t@Test\n\tvoid GetChannelMessages_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tPage<ChannelMessageItem> channelMessages = new PageImpl<>(List.of(ChannelMessageItemFakes.createChannelMessageItem(), ChannelMessageItemFakes.createChannelMessageItem()));\n\n\t\twhen(channelRsService.findAllMessages(eq(groupId), any(Pageable.class))).thenReturn(channelMessages);\n\t\twhen(channelMessageService.getAuthorsMapFromMessages(channelMessages)).thenReturn(Collections.emptyMap());\n\t\twhen(channelMessageService.getMessagesMapFromSummaries(groupId, channelMessages)).thenReturn(Collections.emptyMap());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId + \"/messages\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.content.size()\").value(is(channelMessages.getTotalElements()), Long.class));\n\n\t\tverify(channelRsService).findAllMessages(eq(groupId), any(Pageable.class));\n\t\tverify(channelMessageService).getAuthorsMapFromMessages(channelMessages);\n\t\tverify(channelMessageService).getMessagesMapFromSummaries(groupId, channelMessages);\n\t}\n\n\t@Test\n\tvoid GetChannelMessage_Success() throws Exception\n\t{\n\t\tlong id = 1L;\n\t\tChannelMessageItem channelMessage = ChannelMessageItemFakes.createChannelMessageItem();\n\n\t\twhen(channelRsService.findMessageById(id)).thenReturn(Optional.of(channelMessage));\n\t\twhen(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty());\n\t\twhen(channelRsService.findAllMessagesIncludingOlds(any(GxsId.class), anySet())).thenReturn(List.of());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/messages/\" + id))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is((int) channelMessage.getId())));\n\n\t\tverify(channelRsService).findMessageById(id);\n\t\tverify(identityService).findByGxsId(null);\n\t\tverify(channelRsService).findAllMessagesIncludingOlds(any(GxsId.class), anySet());\n\t}\n\n\t@Test\n\tvoid CreateChannelMessage_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(channelRsService.createChannelMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\teq(1L),\n\t\t\t\teq(\"Test Title\"),\n\t\t\t\teq(\"Test Content\"),\n\t\t\t\tany(),\n\t\t\t\tany(),\n\t\t\t\teq(0L)\n\t\t)).thenReturn(1L);\n\n\t\tmvc.perform(multipart(BASE_URL + \"/messages\")\n\t\t\t\t\t\t.param(\"channelId\", \"1\")\n\t\t\t\t\t\t.param(\"title\", \"Test Title\")\n\t\t\t\t\t\t.param(\"content\", \"Test Content\"))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + CHANNELS_PATH + \"/messages/\" + 1L));\n\n\t\tverify(channelRsService).createChannelMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\tanyLong(),\n\t\t\t\tanyString(),\n\t\t\t\tanyString(),\n\t\t\t\tany(),\n\t\t\t\tany(),\n\t\t\t\tanyLong()\n\t\t);\n\t}\n\n\t@Test\n\tvoid CreateChannelMessage_WithOptionalFields_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(channelRsService.createChannelMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\teq(1L),\n\t\t\t\teq(\"Test Title\"),\n\t\t\t\teq(\"Test Content\"),\n\t\t\t\tany(),\n\t\t\t\tany(),\n\t\t\t\teq(5L)\n\t\t)).thenReturn(1L);\n\n\t\tmvc.perform(multipart(BASE_URL + \"/messages\")\n\t\t\t\t\t\t.param(\"channelId\", \"1\")\n\t\t\t\t\t\t.param(\"title\", \"Test Title\")\n\t\t\t\t\t\t.param(\"content\", \"Test Content\")\n\t\t\t\t\t\t.param(\"originalId\", \"5\"))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + CHANNELS_PATH + \"/messages/\" + 1L));\n\n\t\tverify(channelRsService).createChannelMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\tanyLong(),\n\t\t\t\tanyString(),\n\t\t\t\tanyString(),\n\t\t\t\tany(),\n\t\t\t\tany(),\n\t\t\t\tanyLong()\n\t\t);\n\t}\n\n\t@Test\n\tvoid DownloadChannelGroupImage_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tbyte[] pngImage = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d};\n\t\tvar channelGroupItem = ChannelGroupItemFakes.createChannelGroupItem();\n\t\tchannelGroupItem.setImage(pngImage);\n\n\t\twhen(channelRsService.findById(groupId)).thenReturn(Optional.of(channelGroupItem));\n\n\t\tmvc.perform(get(CHANNELS_PATH + \"/groups/\" + groupId + \"/image\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.IMAGE_PNG));\n\t}\n\n\t@Test\n\tvoid DownloadChannelGroupImage_Empty_Returns204() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tvar channelGroupItem = ChannelGroupItemFakes.createChannelGroupItem();\n\n\t\twhen(channelRsService.findById(groupId)).thenReturn(Optional.of(channelGroupItem));\n\n\t\tmvc.perform(get(CHANNELS_PATH + \"/groups/\" + groupId + \"/image\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isNoContent());\n\t}\n\n\t@Test\n\tvoid DownloadChannelGroupImage_NotFound() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\twhen(channelRsService.findById(groupId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(get(CHANNELS_PATH + \"/groups/\" + groupId + \"/image\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid DownloadChannelMessageImage_Success() throws Exception\n\t{\n\t\tlong messageId = 1L;\n\t\tbyte[] jpegImage = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff, (byte) 0xe0, 0x00, 0x00, 0x00, 0x00};\n\t\tvar channelMessageItem = ChannelMessageItemFakes.createChannelMessageItem();\n\t\tchannelMessageItem.setImage(jpegImage);\n\n\t\twhen(channelRsService.findMessageById(messageId)).thenReturn(Optional.of(channelMessageItem));\n\n\t\tmvc.perform(get(CHANNELS_PATH + \"/messages/\" + messageId + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.IMAGE_JPEG));\n\t}\n\n\t@Test\n\tvoid DownloadChannelMessageImage_Empty_Returns204() throws Exception\n\t{\n\t\tlong messageId = 1L;\n\t\tvar channelMessageItem = ChannelMessageItemFakes.createChannelMessageItem();\n\n\t\twhen(channelRsService.findMessageById(messageId)).thenReturn(Optional.of(channelMessageItem));\n\n\t\tmvc.perform(get(CHANNELS_PATH + \"/messages/\" + messageId + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isNoContent());\n\t}\n\n\t@Test\n\tvoid DownloadChannelMessageImage_NotFound() throws Exception\n\t{\n\t\tlong messageId = 1L;\n\n\t\twhen(channelRsService.findMessageById(messageId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(get(CHANNELS_PATH + \"/messages/\" + messageId + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/chat/ChatControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.chat;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.chat.ChatBacklog;\nimport io.xeres.app.database.model.chat.ChatRoomBacklog;\nimport io.xeres.app.database.model.chat.ChatRoomFakes;\nimport io.xeres.app.database.model.chat.DistantChatBacklog;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.xrs.service.chat.ChatBacklogService;\nimport io.xeres.app.xrs.service.chat.ChatRsService;\nimport io.xeres.app.xrs.service.chat.RoomFlags;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.chat.ChatRoomContext;\nimport io.xeres.common.message.chat.ChatRoomInfo;\nimport io.xeres.common.message.chat.ChatRoomLists;\nimport io.xeres.common.message.chat.ChatRoomUser;\nimport io.xeres.common.rest.chat.ChatRoomVisibility;\nimport io.xeres.common.rest.chat.CreateChatRoomRequest;\nimport io.xeres.common.rest.chat.InviteToChatRoomRequest;\nimport org.bouncycastle.util.encoders.Base64;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.time.Instant;\nimport java.time.LocalDateTime;\nimport java.time.ZoneOffset;\nimport java.time.temporal.ChronoUnit;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.common.rest.PathConfig.CHAT_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.*;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(ChatController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ChatControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = CHAT_PATH;\n\n\t@MockitoBean\n\tprivate ChatRsService chatRsService;\n\n\t@MockitoBean\n\tprivate ChatBacklogService chatBacklogService;\n\n\t@MockitoBean\n\tprivate LocationService locationService;\n\n\t@MockitoBean\n\tprivate IdentityService identityService;\n\n\t@Test\n\tvoid CreateChatRoom_Public_Success() throws Exception\n\t{\n\t\tvar chatRoomRequest = new CreateChatRoomRequest(\"The Elephant Room\", \"Nothing to see here\", ChatRoomVisibility.PUBLIC, false);\n\n\t\twhen(chatRsService.createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.of(RoomFlags.PUBLIC), false)).thenReturn(1L);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/rooms\", chatRoomRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + CHAT_PATH + \"/rooms/\" + 1L));\n\n\t\tverify(chatRsService).createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.of(RoomFlags.PUBLIC), false);\n\t}\n\n\t@Test\n\tvoid CreateChatRoom_Private_Success() throws Exception\n\t{\n\t\tvar chatRoomRequest = new CreateChatRoomRequest(\"The Elephant Room\", \"Nothing to see here\", ChatRoomVisibility.PRIVATE, false);\n\n\t\twhen(chatRsService.createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.noneOf(RoomFlags.class), false)).thenReturn(1L);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/rooms\", chatRoomRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + CHAT_PATH + \"/rooms/\" + 1L));\n\n\t\tverify(chatRsService).createChatRoom(chatRoomRequest.name(), chatRoomRequest.topic(), EnumSet.noneOf(RoomFlags.class), false);\n\t}\n\n\t@Test\n\tvoid InviteToChatRoom_Success() throws Exception\n\t{\n\t\tvar chatRoomId = 1L;\n\t\tvar locations = Set.of(LocationFakes.createLocation().getLocationIdentifier(), LocationFakes.createLocation().getLocationIdentifier());\n\n\t\tvar inviteRequest = new InviteToChatRoomRequest(chatRoomId, locations.stream()\n\t\t\t\t.map(LocationIdentifier::toString)\n\t\t\t\t.collect(Collectors.toSet()));\n\n\t\tmvc.perform(postJson(BASE_URL + \"/rooms/invite\", inviteRequest))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(chatRsService).inviteLocationsToChatRoom(chatRoomId, locations);\n\t}\n\n\t@Test\n\tvoid SubscribeToChatRoom_Success() throws Exception\n\t{\n\t\tvar id = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/rooms/\" + id + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(chatRsService).joinChatRoom(id);\n\t}\n\n\t@Test\n\tvoid UnsubscribeFromChatRoom_Success() throws Exception\n\t{\n\t\tvar id = 1L;\n\n\t\tmvc.perform(delete(BASE_URL + \"/rooms/\" + id + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(chatRsService).leaveChatRoom(id);\n\t}\n\n\t@Test\n\tvoid GetChatRoomContext_Success() throws Exception\n\t{\n\t\tvar subscribedChatRoom = new ChatRoomInfo(\"SubscribedRoom\");\n\t\tvar availableChatRoom = new ChatRoomInfo(\"AvailableRoom\");\n\t\tvar chatRoomLists = new ChatRoomLists();\n\t\tchatRoomLists.addSubscribed(subscribedChatRoom);\n\t\tchatRoomLists.addAvailable(availableChatRoom);\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\t\tvar chatRoomUser = new ChatRoomUser(ownIdentity.getName(), ownIdentity.getGxsId(), ownIdentity.getId());\n\t\twhen(chatRsService.getChatRoomContext()).thenReturn(new ChatRoomContext(chatRoomLists, chatRoomUser));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/rooms\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.chatRooms.subscribed[0].name\", is(subscribedChatRoom.getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.chatRooms.available[0].name\", is(availableChatRoom.getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.identity.nickname\", is(ownIdentity.getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.identity.gxsId.bytes\", is(Base64.toBase64String(ownIdentity.getGxsId().getBytes()))));\n\n\t\tverify(chatRsService).getChatRoomContext();\n\t}\n\n\t@Test\n\tvoid GetChatMessages_Default_Success() throws Exception\n\t{\n\t\tvar creation = Instant.now();\n\t\tvar location = LocationFakes.createLocation();\n\t\tvar chatBacklog = new ChatBacklog(location, false, \"hey\");\n\t\tchatBacklog.setCreated(creation);\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\t\twhen(chatBacklogService.getMessages(eq(location), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/chats/\" + location.getId() + \"/messages\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0].created\", is(creation.toString())))\n\t\t\t\t.andExpect(jsonPath(\"$[0].own\", is(false)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].message\", is(\"hey\")));\n\n\t\tverify(chatBacklogService).getMessages(eq(location), any(Instant.class), eq(20));\n\t}\n\n\t@Test\n\tvoid GetChatMessages_WithParameters_Success() throws Exception\n\t{\n\t\tvar creation = Instant.now();\n\t\tvar location = LocationFakes.createLocation();\n\t\tvar chatBacklog = new ChatBacklog(location, false, \"hey\");\n\t\tchatBacklog.setCreated(creation);\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\t\twhen(chatBacklogService.getMessages(eq(location), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/chats/\" + location.getId() + \"/messages?maxLines=30&from=2024-12-23T22:13\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0].created\", is(creation.toString())))\n\t\t\t\t.andExpect(jsonPath(\"$[0].own\", is(false)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].message\", is(\"hey\")));\n\n\t\tverify(chatBacklogService).getMessages(location, LocalDateTime.parse(\"2024-12-23T22:13\").toInstant(ZoneOffset.UTC), 30);\n\t}\n\n\t@Test\n\tvoid GetChatRoomMessages_Default_Success() throws Exception\n\t{\n\t\tvar creation = Instant.now();\n\t\tvar chatRoom = ChatRoomFakes.createChatRoomEntity();\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\t\tvar chatRoomBacklog = new ChatRoomBacklog(chatRoom, ownIdentity.getGxsId(), \"Foobar\", \"blabla\");\n\t\tchatRoomBacklog.setCreated(creation);\n\n\t\twhen(chatBacklogService.getChatRoomMessages(eq(chatRoom.getRoomId()), any(Instant.class), anyInt())).thenReturn(List.of(chatRoomBacklog));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/rooms/\" + chatRoom.getRoomId() + \"/messages\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0].created\", is(creation.toString())))\n\t\t\t\t.andExpect(jsonPath(\"$[0].gxsId.bytes\", is(Base64.toBase64String(ownIdentity.getGxsId().getBytes()))))\n\t\t\t\t.andExpect(jsonPath(\"$[0].nickname\", is(\"Foobar\")))\n\t\t\t\t.andExpect(jsonPath(\"$[0].message\", is(\"blabla\")));\n\n\t\tverify(chatBacklogService).getChatRoomMessages(eq(chatRoom.getRoomId()), any(Instant.class), eq(50));\n\t}\n\n\t@Test\n\tvoid GetChatRoomMessages_WithParameters_Success() throws Exception\n\t{\n\t\tvar creation = Instant.now();\n\t\tvar chatRoom = ChatRoomFakes.createChatRoomEntity();\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\t\tvar chatRoomBacklog = new ChatRoomBacklog(chatRoom, ownIdentity.getGxsId(), \"Foobar\", \"blabla\");\n\t\tchatRoomBacklog.setCreated(creation);\n\n\t\twhen(chatBacklogService.getChatRoomMessages(eq(chatRoom.getRoomId()), any(Instant.class), anyInt())).thenReturn(List.of(chatRoomBacklog));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/rooms/\" + chatRoom.getRoomId() + \"/messages?maxLines=80&from=2024-12-24T01:27\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0].created\", is(creation.toString())))\n\t\t\t\t.andExpect(jsonPath(\"$[0].gxsId.bytes\", is(Base64.toBase64String(ownIdentity.getGxsId().getBytes()))))\n\t\t\t\t.andExpect(jsonPath(\"$[0].nickname\", is(\"Foobar\")))\n\t\t\t\t.andExpect(jsonPath(\"$[0].message\", is(\"blabla\")));\n\n\t\tverify(chatBacklogService).getChatRoomMessages(chatRoom.getRoomId(), LocalDateTime.parse(\"2024-12-24T01:27\").toInstant(ZoneOffset.UTC), 80);\n\t}\n\n\t@Test\n\tvoid DeleteChatMessages_Success() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\n\t\tmvc.perform(delete(BASE_URL + \"/chats/\" + location.getId() + \"/messages\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(chatBacklogService).deleteMessages(location);\n\t}\n\n\t@Test\n\tvoid DeleteChatMessages_NotFound() throws Exception\n\t{\n\t\tvar locationId = 123L;\n\t\twhen(locationService.findLocationById(locationId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(delete(BASE_URL + \"/chats/\" + locationId + \"/messages\"))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid DeleteChatRoomMessages_Success() throws Exception\n\t{\n\t\tvar chatRoomId = 1L;\n\n\t\tmvc.perform(delete(BASE_URL + \"/rooms/\" + chatRoomId + \"/messages\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(chatBacklogService).deleteChatRoomMessages(chatRoomId);\n\t}\n\n\t@Test\n\tvoid CreateDistantChat_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar request = new io.xeres.common.rest.chat.DistantChatRequest(identity.getId());\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\t\tvar location = LocationFakes.createLocation();\n\t\twhen(chatRsService.createDistantChat(identity)).thenReturn(location);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/distant-chats\", request))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(chatRsService).createDistantChat(identity);\n\t}\n\n\t@Test\n\tvoid CreateDistantChat_AlreadyExists() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar request = new io.xeres.common.rest.chat.DistantChatRequest(identity.getId());\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\t\twhen(chatRsService.createDistantChat(identity)).thenReturn(null);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/distant-chats\", request))\n\t\t\t\t.andExpect(status().isConflict());\n\n\t\tverify(chatRsService).createDistantChat(identity);\n\t}\n\n\t@Test\n\tvoid CreateDistantChat_IdentityNotFound() throws Exception\n\t{\n\t\tvar identityId = 42L;\n\t\tvar request = new io.xeres.common.rest.chat.DistantChatRequest(identityId);\n\t\twhen(identityService.findById(identityId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(postJson(BASE_URL + \"/distant-chats\", request))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid CloseDistantChat_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\t\twhen(chatRsService.closeDistantChat(identity)).thenReturn(true);\n\n\t\tmvc.perform(delete(BASE_URL + \"/distant-chats/\" + identity.getId()))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(chatRsService).closeDistantChat(identity);\n\t}\n\n\t@Test\n\tvoid CloseDistantChat_NotFound() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\t\twhen(chatRsService.closeDistantChat(identity)).thenReturn(false);\n\n\t\tmvc.perform(delete(BASE_URL + \"/distant-chats/\" + identity.getId()))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(chatRsService).closeDistantChat(identity);\n\t}\n\n\t@Test\n\tvoid CloseDistantChat_IdentityNotFound() throws Exception\n\t{\n\t\tvar identityId = 99L;\n\t\twhen(identityService.findById(identityId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(delete(BASE_URL + \"/distant-chats/\" + identityId))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(identityService).findById(identityId);\n\t}\n\n\t@Test\n\tvoid GetDistantChatMessages_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar creation = Instant.now();\n\t\tvar chatBacklog = new DistantChatBacklog(identity, false, \"hello\");\n\t\tchatBacklog.setCreated(creation);\n\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\t\twhen(chatBacklogService.getDistantMessages(eq(identity), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/distant-chats/\" + identity.getId() + \"/messages\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0].created\", is(creation.toString())))\n\t\t\t\t.andExpect(jsonPath(\"$[0].message\", is(\"hello\")));\n\n\t\tverify(identityService).findById(identity.getId());\n\t\tverify(chatBacklogService).getDistantMessages(eq(identity), any(Instant.class), anyInt());\n\t}\n\n\t@Test\n\tvoid GetDistantChatMessages_Parameters_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar creation = Instant.now();\n\t\tvar from = creation.minus(1, ChronoUnit.DAYS);\n\t\tvar chatBacklog = new DistantChatBacklog(identity, false, \"hello\");\n\t\tchatBacklog.setCreated(creation);\n\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\t\twhen(chatBacklogService.getDistantMessages(eq(identity), any(Instant.class), anyInt())).thenReturn(List.of(chatBacklog));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/distant-chats/\" + identity.getId() + \"/messages?from=\" + from.toString() + \"&maxLines=5\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0].created\", is(creation.toString())))\n\t\t\t\t.andExpect(jsonPath(\"$[0].message\", is(\"hello\")));\n\n\t\tverify(identityService).findById(identity.getId());\n\t\tverify(chatBacklogService).getDistantMessages(identity, from, 5);\n\t}\n\n\t@Test\n\tvoid GetDistantChatMessages_IdentityNotFound() throws Exception\n\t{\n\t\tvar identityId = 77L;\n\t\twhen(identityService.findById(identityId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/distant-chats/\" + identityId + \"/messages\"))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(identityService).findById(identityId);\n\t}\n\n\t@Test\n\tvoid DeleteDistantChatMessages_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\n\t\tmvc.perform(delete(BASE_URL + \"/distant-chats/\" + identity.getId() + \"/messages\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(chatBacklogService).deleteDistantMessages(identity);\n\t}\n\n\t@Test\n\tvoid DeleteDistantChatMessages_IdentityNotFound() throws Exception\n\t{\n\t\tvar identityId = 88L;\n\t\twhen(identityService.findById(identityId)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(delete(BASE_URL + \"/distant-chats/\" + identityId + \"/messages\"))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(identityService).findById(identityId);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/chat/ChatMessageControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.chat;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.service.MessageService;\nimport io.xeres.app.xrs.service.chat.ChatRsService;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.MessagePath;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.message.chat.ChatRoomMessage;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.verify;\n\n@ExtendWith(MockitoExtension.class)\nclass ChatMessageControllerTest\n{\n\t@Mock\n\tprivate ChatRsService chatRsService;\n\n\t@Mock\n\tprivate MessageService messageService;\n\n\t@InjectMocks\n\tprivate ChatMessageController controller;\n\n\t@Test\n\tvoid processPrivateChatMessage_sendsPrivateMessageAndNotifiesConsumers()\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\tString dest = location.getLocationIdentifier().toString();\n\t\tvar msg = new ChatMessage(\"hello\");\n\n\t\tcontroller.processPrivateChatMessageFromProducer(dest, MessageType.CHAT_PRIVATE_MESSAGE, msg);\n\n\t\tverify(chatRsService).sendPrivateMessage(LocationIdentifier.fromString(dest), \"hello\");\n\t\tverify(messageService).sendToConsumers(MessagePath.chatPrivateDestination(), MessageType.CHAT_PRIVATE_MESSAGE, LocationIdentifier.fromString(dest), msg);\n\t\tassertTrue(msg.isOwn());\n\t}\n\n\t@Test\n\tvoid processDistantChatMessage_sendsPrivateMessageAndNotifiesConsumers()\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\tString dest = location.getLocationIdentifier().toString();\n\t\tvar msg = new ChatMessage(\"hiya\");\n\n\t\tcontroller.processDistantChatMessageFromProducer(dest, MessageType.CHAT_PRIVATE_MESSAGE, msg);\n\n\t\tverify(chatRsService).sendPrivateMessage(GxsId.fromString(dest), \"hiya\");\n\t\tverify(messageService).sendToConsumers(MessagePath.chatDistantDestination(), MessageType.CHAT_PRIVATE_MESSAGE, GxsId.fromString(dest), msg);\n\t\tassertTrue(msg.isOwn());\n\t}\n\n\t@Test\n\tvoid processChatRoomMessage_sendsRoomMessageAndNotifiesConsumers()\n\t{\n\t\tString dest = \"42\";\n\t\tvar crm = new ChatRoomMessage(null, null, \"roommsg\");\n\n\t\tcontroller.processChatRoomMessageFromProducer(dest, MessageType.CHAT_ROOM_MESSAGE, crm);\n\n\t\tverify(chatRsService).sendChatRoomMessage(42L, \"roommsg\");\n\t\tverify(messageService).sendToConsumers(MessagePath.chatRoomDestination(), MessageType.CHAT_ROOM_MESSAGE, 42L, crm);\n\t}\n\n\t@Test\n\tvoid processBroadcastMessage_sendsBroadcast()\n\t{\n\t\tvar msg = new ChatMessage(\"broadcast\");\n\n\t\tcontroller.processBroadcastMessageFromProducer(MessageType.CHAT_BROADCAST_MESSAGE, msg);\n\n\t\tverify(chatRsService).sendBroadcastMessage(\"broadcast\");\n\t}\n\n\t@Test\n\tvoid handleException_returnsMessage()\n\t{\n\t\tvar ex = new RuntimeException(\"boom\");\n\t\tvar result = controller.handleException(ex);\n\t\tassertEquals(\"boom\", result);\n\t}\n\n\t@Test\n\tvoid processingUnexpectedMessageType_throwsIllegalStateException()\n\t{\n\t\tString dest = \"00000000000000000000000000000000\";\n\t\tvar msg = new ChatMessage(\"oops\");\n\n\t\tassertThrows(IllegalStateException.class, () -> controller.processPrivateChatMessageFromProducer(dest, MessageType.CHAT_BROADCAST_MESSAGE, msg));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/config/ConfigControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.config;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.*;\nimport io.xeres.app.service.backup.BackupService;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport io.xeres.app.xrs.service.status.StatusRsService;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.config.*;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.NullAndEmptySource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.mock.web.MockMultipartFile;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static io.xeres.common.rest.PathConfig.*;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.Mockito.*;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(ConfigController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ConfigControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = CONFIG_PATH;\n\n\t@MockitoBean\n\tprivate ProfileService profileService;\n\n\t@MockitoBean\n\tprivate LocationService locationService;\n\n\t@MockitoBean\n\tprivate IdentityRsService identityRsService;\n\n\t@MockitoBean\n\tprivate CapabilityService capabilityService;\n\n\t@MockitoBean\n\tprivate BackupService backupService;\n\n\t@MockitoBean\n\tprivate NetworkService networkService;\n\n\t@MockitoBean\n\tprivate StatusRsService statusRsService;\n\n\t@Test\n\tvoid CreateProfile_Success() throws Exception\n\t{\n\t\tvar profileRequest = new OwnProfileRequest(\"test node\");\n\n\t\twhen(profileService.generateProfileKeys(profileRequest.name())).thenReturn(ResourceCreationState.CREATED);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/profile\", profileRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + PROFILES_PATH + \"/\" + 1L));\n\n\t\tverify(profileService).generateProfileKeys(profileRequest.name());\n\t}\n\n\t@Test\n\tvoid CreateProfile_Failure() throws Exception\n\t{\n\t\tvar ownProfileRequest = new OwnProfileRequest(\"test node\");\n\n\t\twhen(profileService.generateProfileKeys(ownProfileRequest.name())).thenReturn(ResourceCreationState.FAILED);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/profile\", ownProfileRequest))\n\t\t\t\t.andExpect(status().isInternalServerError());\n\n\t\tverify(profileService).generateProfileKeys(ownProfileRequest.name());\n\t}\n\n\t@Test\n\tvoid CreateProfile_AlreadyExists_Failure() throws Exception\n\t{\n\t\tvar profileRequest = new OwnProfileRequest(\"test node\");\n\n\t\twhen(profileService.generateProfileKeys(profileRequest.name())).thenReturn(ResourceCreationState.ALREADY_EXISTS);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/profile\", profileRequest))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(profileService).generateProfileKeys(profileRequest.name());\n\t}\n\n\t@ParameterizedTest\n\t@NullAndEmptySource\n\t@ValueSource(strings = {\n\t\t\t\"This name is way too long and there's no chance it ever gets created as a profile\"\n\t})\n\tvoid CreateProfile_BadName_Failure(String name) throws Exception\n\t{\n\t\tvar ownProfileRequest = new OwnProfileRequest(name);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/profile\", ownProfileRequest))\n\t\t\t\t.andExpect(status().isBadRequest());\n\n\t\tverifyNoInteractions(profileService);\n\t}\n\n\t@Test\n\tvoid CreateLocation_Success() throws Exception\n\t{\n\t\tvar ownLocationRequest = new OwnLocationRequest(\"test location\");\n\n\t\tmvc.perform(postJson(BASE_URL + \"/location\", ownLocationRequest))\n\t\t\t\t.andExpect(status().isCreated());\n\n\t\tverify(locationService).generateOwnLocation(anyString());\n\t}\n\n\t@Test\n\tvoid CreateLocation_AlreadyExists_Success() throws Exception\n\t{\n\t\tvar ownLocationRequest = new OwnLocationRequest(\"test location\");\n\n\t\twhen(locationService.generateOwnLocation(anyString())).thenReturn(ResourceCreationState.ALREADY_EXISTS);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/location\", ownLocationRequest))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(locationService).generateOwnLocation(anyString());\n\t}\n\n\t@Test\n\tvoid CreateLocation_Failure() throws Exception\n\t{\n\t\tvar ownLocationRequest = new OwnLocationRequest(\"test location\");\n\n\t\twhen(locationService.generateOwnLocation(anyString())).thenReturn(ResourceCreationState.FAILED);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/location\", ownLocationRequest))\n\t\t\t\t.andExpect(status().isInternalServerError());\n\t}\n\n\t@ParameterizedTest\n\t@NullAndEmptySource\n\t@ValueSource(strings = {\n\t\t\t\"This name is way too long and there's no chance it ever gets created as a location\"\n\t})\n\tvoid CreateLocation_BadName_Failure(String name) throws Exception\n\t{\n\t\tvar ownLocationRequest = new OwnLocationRequest(name);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/location\", ownLocationRequest))\n\t\t\t\t.andExpect(status().isBadRequest());\n\n\t\tverifyNoInteractions(locationService);\n\t}\n\n\t@Test\n\tvoid GetExternalIpAddress_Success() throws Exception\n\t{\n\t\tvar ip = \"1.1.1.1\";\n\t\tvar port = 6667;\n\n\t\tvar location = Location.createLocation(\"test\");\n\t\tvar connection = Connection.from(PeerAddress.from(ip, port));\n\t\tlocation.addConnection(connection);\n\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(location));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/external-ip\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.ip\", is(ip)))\n\t\t\t\t.andExpect(jsonPath(\"$.port\", is(port)));\n\t}\n\n\t@Test\n\tvoid GetExternalIpAddress_NoLocationOrIpAddress_Success() throws Exception\n\t{\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.empty());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/external-ip\"))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid GetInternalIpAddress_Success() throws Exception\n\t{\n\t\tvar ip = \"192.168.1.25\";\n\t\tvar port = 1234;\n\n\t\tvar location = Location.createLocation(\"test\");\n\t\tvar connection = Connection.from(PeerAddress.from(ip, port));\n\t\tlocation.addConnection(connection);\n\n\t\twhen(networkService.getLocalIpAddress()).thenReturn(ip);\n\t\twhen(networkService.getPort()).thenReturn(port);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/internal-ip\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.ip\", is(ip)))\n\t\t\t\t.andExpect(jsonPath(\"$.port\", is(port)));\n\t}\n\n\t@Test\n\tvoid GetInternalIpAddress_NoLocationOrIpAddress_Success() throws Exception\n\t{\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.empty());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/internalIp\"))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid GetHostname_Success() throws Exception\n\t{\n\t\tvar hostname = \"foo.bar.com\";\n\n\t\twhen(locationService.getHostname()).thenReturn(hostname);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/hostname\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.hostname\", is(hostname)));\n\t}\n\n\t@Test\n\tvoid GetUsername_Success() throws Exception\n\t{\n\t\tvar username = \"foobar\";\n\t\twhen(locationService.getUsername()).thenReturn(username);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/username\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.username\", is(username)));\n\t}\n\n\t@Test\n\tvoid CreateIdentity_Signed_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar identityRequest = new OwnIdentityRequest(identity.getName(), false);\n\n\t\twhen(identityRsService.generateOwnIdentity(identityRequest.name(), true)).thenReturn(ResourceCreationState.CREATED);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/identity\", identityRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + IDENTITIES_PATH + \"/\" + identity.getId()));\n\n\t\tverify(identityRsService).generateOwnIdentity(identityRequest.name(), true);\n\t}\n\n\t@Test\n\tvoid CreateIdentity_Anonymous_Success() throws Exception\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar identityRequest = new OwnIdentityRequest(identity.getName(), true);\n\n\t\twhen(identityRsService.generateOwnIdentity(identityRequest.name(), false)).thenReturn(ResourceCreationState.CREATED);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/identity\", identityRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + IDENTITIES_PATH + \"/\" + identity.getId()));\n\n\t\tverify(identityRsService).generateOwnIdentity(identityRequest.name(), false);\n\t}\n\n\t@Test\n\tvoid GetCapabilities_Success() throws Exception\n\t{\n\t\tvar capability = \"autostart\";\n\t\twhen(capabilityService.getCapabilities()).thenReturn(Set.of(capability));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/capabilities\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$[0]\", is(capability)));\n\n\t\tverify(capabilityService).getCapabilities();\n\t}\n\n\t@Test\n\tvoid CreateIdentity_Failure() throws Exception\n\t{\n\t\tvar identityRequest = new OwnIdentityRequest(\"test identity\", false);\n\n\t\twhen(identityRsService.generateOwnIdentity(identityRequest.name(), true)).thenReturn(ResourceCreationState.FAILED);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/identity\", identityRequest))\n\t\t\t\t.andExpect(status().isInternalServerError());\n\n\t\tverify(identityRsService).generateOwnIdentity(identityRequest.name(), true);\n\t}\n\n\t@Test\n\tvoid CreateIdentity_AlreadyExists() throws Exception\n\t{\n\t\tvar identityRequest = new OwnIdentityRequest(\"test identity\", false);\n\n\t\twhen(identityRsService.generateOwnIdentity(identityRequest.name(), true)).thenReturn(ResourceCreationState.ALREADY_EXISTS);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/identity\", identityRequest))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(identityRsService).generateOwnIdentity(identityRequest.name(), true);\n\t}\n\n\t@Test\n\tvoid ChangeAvailability_Success() throws Exception\n\t{\n\t\tvar availability = Availability.AVAILABLE;\n\t\twhen(locationService.hasOwnLocation()).thenReturn(true);\n\n\t\tmvc.perform(putJson(BASE_URL + \"/location/availability\", availability))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(statusRsService).changeAvailability(availability);\n\t}\n\n\t@Test\n\tvoid ChangeAvailability_NoLocation_Failure() throws Exception\n\t{\n\t\tvar availability = Availability.AVAILABLE;\n\t\twhen(locationService.hasOwnLocation()).thenReturn(false);\n\n\t\tmvc.perform(putJson(BASE_URL + \"/location/availability\", availability))\n\t\t\t\t.andExpect(status().isBadRequest());\n\n\t\tverifyNoInteractions(statusRsService);\n\t}\n\n\t@Test\n\tvoid GetExport_Success() throws Exception\n\t{\n\t\tvar backupData = \"backup data\".getBytes();\n\t\twhen(backupService.backup()).thenReturn(backupData);\n\n\t\tmvc.perform(get(BASE_URL + \"/export\", MediaType.APPLICATION_XML))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, \"attachment; filename=\\\"xeres_backup.xml\\\"\"))\n\t\t\t\t.andExpect(content().bytes(backupData));\n\n\t\tverify(backupService).backup();\n\t}\n\n\t@Test\n\tvoid ImportBackup_Success() throws Exception\n\t{\n\t\tvar file = new MockMultipartFile(\"file\", \"xeres_backup.xml\", MediaType.APPLICATION_XML_VALUE, \"backup data\".getBytes());\n\n\t\tmvc.perform(multipart(BASE_URL + \"/import\")\n\t\t\t\t\t\t.file(file))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(backupService).restore(any());\n\t\tverify(networkService).checkReadiness();\n\t}\n\n\t@Test\n\tvoid ImportProfileFromRs_Success() throws Exception\n\t{\n\t\tvar file = new MockMultipartFile(\"file\", \"retroshare_secret_keyring.gpg\", MediaType.APPLICATION_OCTET_STREAM_VALUE, \"data\".getBytes());\n\t\tvar locationName = \"test location\";\n\t\tvar password = \"secret\";\n\n\t\tmvc.perform(multipart(BASE_URL + \"/import-profile-from-rs\")\n\t\t\t\t\t\t.file(file)\n\t\t\t\t\t\t.param(\"locationName\", locationName)\n\t\t\t\t\t\t.param(\"password\", password))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(backupService).importProfileFromRs(file, locationName, password);\n\t\tverify(networkService).checkReadiness();\n\t}\n\n\t@Test\n\tvoid ImportFriendsFromRs_Success() throws Exception\n\t{\n\t\tvar file = new MockMultipartFile(\"file\", \"friends.xml\", MediaType.APPLICATION_XML_VALUE, \"data\".getBytes());\n\n\t\twhen(backupService.importFriendsFromRs(file)).thenReturn(new ImportRsFriendsResponse(1, 0));\n\n\t\tmvc.perform(multipart(BASE_URL + \"/import-friends-from-rs\")\n\t\t\t\t\t\t.file(file))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(backupService).importFriendsFromRs(file);\n\t}\n\n\t@Test\n\tvoid ImportFriendsFromRs_Errors() throws Exception\n\t{\n\t\tvar file = new MockMultipartFile(\"file\", \"friends.xml\", MediaType.APPLICATION_XML_VALUE, \"data\".getBytes());\n\n\t\twhen(backupService.importFriendsFromRs(file)).thenReturn(new ImportRsFriendsResponse(1, 1));\n\n\t\tmvc.perform(multipart(BASE_URL + \"/import-friends-from-rs\")\n\t\t\t\t\t\t.file(file))\n\t\t\t\t.andExpect(status().is(HttpStatus.MULTI_STATUS.value()));\n\n\t\tverify(backupService).importFriendsFromRs(file);\n\t}\n\n\t@Test\n\tvoid VerifyUpdate_Success() throws Exception\n\t{\n\t\tvar request = new VerifyUpdateRequest(\"Xeres.msi\", \"signature\".getBytes());\n\t\twhen(backupService.verifyUpdate(any(), eq(request.signature()))).thenReturn(true);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/verify-update\", request))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().string(\"true\"));\n\n\t\tverify(backupService).verifyUpdate(any(), eq(request.signature()));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/connection/ConnectionControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.connection;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.job.PeerConnectionJob;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.common.rest.connection.ConnectionRequest;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.List;\nimport java.util.Optional;\n\nimport static io.xeres.common.rest.PathConfig.CONNECTIONS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(ConnectionController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ConnectionControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = CONNECTIONS_PATH;\n\n\t@MockitoBean\n\tprivate LocationService locationService;\n\n\t@MockitoBean\n\tprivate PeerConnectionJob peerConnectionJob;\n\n\t@Test\n\tvoid GetConnectedProfiles_Success() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\tvar locations = List.of(LocationFakes.createOwnLocation(),\n\t\t\t\tlocation);\n\t\twhen(locationService.getConnectedLocations()).thenReturn(locations);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/profiles\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.length()\").value(is(1)))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(location.getProfile().getId()), Long.class));\n\n\t\tverify(locationService).getConnectedLocations();\n\t}\n\n\t@Test\n\tvoid AttemptToConnect_Success() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\twhen(locationService.findLocationByLocationIdentifier(location.getLocationIdentifier())).thenReturn(Optional.of(location));\n\n\t\tmvc.perform(putJson(BASE_URL + \"/connect\", new ConnectionRequest(location.getLocationIdentifier().toString(), -1)))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(locationService).findLocationByLocationIdentifier(location.getLocationIdentifier());\n\t\tverify(peerConnectionJob).connectImmediately(location, -1);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/contact/ContactControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.contact;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.service.ContactService;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.contact.Contact;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.CONTACT_PATH;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(ContactController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ContactControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = CONTACT_PATH;\n\n\t@MockitoBean\n\tprivate ContactService contactService;\n\n\t@Test\n\tvoid GetContacts_Success() throws Exception\n\t{\n\t\tvar contacts = List.of(\n\t\t\t\tnew Contact(\"foo\", 1L, 1L, Availability.AVAILABLE, true),\n\t\t\t\tnew Contact(\"bar\", 2L, 2L, Availability.BUSY, true)\n\t\t);\n\n\t\twhen(contactService.getContacts()).thenReturn(contacts);\n\n\t\tmvc.perform(getJson(BASE_URL))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\").value(\"foo\"));\n\n\t\tverify(contactService).getContacts();\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/file/FileControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.file;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.xrs.service.filetransfer.FileTransferRsService;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.rest.file.FileDownloadRequest;\nimport io.xeres.common.rest.file.FileProgress;\nimport io.xeres.common.rest.file.FileSearchRequest;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.FILES_PATH;\nimport static org.hamcrest.Matchers.hasSize;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(FileController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass FileControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = FILES_PATH;\n\n\t@MockitoBean\n\tprivate FileTransferRsService fileTransferRsService;\n\n\t@Test\n\tvoid Search_Success() throws Exception\n\t{\n\t\tvar searchName = \"cool stuff\";\n\t\tvar searchRequest = new FileSearchRequest(searchName);\n\n\t\twhen(fileTransferRsService.turtleSearch(searchName)).thenReturn(1);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/search\", searchRequest))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\", is(1)));\n\n\t\tverify(fileTransferRsService).turtleSearch(searchName);\n\t}\n\n\t@Test\n\tvoid Download_Success() throws Exception\n\t{\n\t\tvar downloadId = 123L;\n\t\tvar request = new FileDownloadRequest(\"test.txt\", \"0123456789abcdef0123456789abcdef01234567\", 1024L, LocationFakes.createLocation().getLocationIdentifier());\n\n\t\twhen(fileTransferRsService.download(eq(request.name()), any(Sha1Sum.class), eq(request.size()), eq(request.locationIdentifier()))).thenReturn(downloadId);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/download\", request))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$\", is((int) downloadId)));\n\t}\n\n\t@Test\n\tvoid Download_InvalidHash_Failure() throws Exception\n\t{\n\t\tvar request = new FileDownloadRequest(\"test.txt\", \"invalid_hash\", 1024L, LocationFakes.createLocation().getLocationIdentifier());\n\n\t\tmvc.perform(postJson(BASE_URL + \"/download\", request))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid GetDownloads_Success() throws Exception\n\t{\n\t\tvar progress = new FileProgress(1L, \"test.txt\", 2048L, 8192L, \"0123456789abcdef0123456789abcdef01234567\", false);\n\t\twhen(fileTransferRsService.getDownloadStatistics()).thenReturn(List.of(progress));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/downloads\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$\", hasSize(1)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].id\", is(1)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].name\", is(\"test.txt\")))\n\t\t\t\t.andExpect(jsonPath(\"$[0].currentSize\", is(2048)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].totalSize\", is(8192)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].hash\", is(\"0123456789abcdef0123456789abcdef01234567\")))\n\t\t\t\t.andExpect(jsonPath(\"$[0].completed\", is(false)));\n\n\t\tverify(fileTransferRsService).getDownloadStatistics();\n\t}\n\n\t@Test\n\tvoid GetUploads_Success() throws Exception\n\t{\n\t\tvar progress = new FileProgress(1L, \"test.txt\", 2048L, 8192L, \"0123456789abcdef0123456789abcdef01234567\", false);\n\t\twhen(fileTransferRsService.getUploadStatistics()).thenReturn(List.of(progress));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/uploads\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$\", hasSize(1)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].id\", is(1)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].name\", is(\"test.txt\")))\n\t\t\t\t.andExpect(jsonPath(\"$[0].currentSize\", is(2048)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].totalSize\", is(8192)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].hash\", is(\"0123456789abcdef0123456789abcdef01234567\")))\n\t\t\t\t.andExpect(jsonPath(\"$[0].completed\", is(false)));\n\n\t\tverify(fileTransferRsService).getUploadStatistics();\n\t}\n\n\t@Test\n\tvoid RemoveDownload_Success() throws Exception\n\t{\n\t\tvar downloadId = 123L;\n\n\t\tmvc.perform(delete(BASE_URL + \"/downloads/\" + downloadId))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(fileTransferRsService).removeDownload(downloadId);\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/forum/ForumControllerTest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.forum;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.forum.ForumMessageItemSummary;\nimport io.xeres.app.database.model.gxs.ForumGroupItemFakes;\nimport io.xeres.app.database.model.gxs.ForumMessageItemFakes;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.service.ForumMessageService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.xrs.service.forum.ForumRsService;\nimport io.xeres.app.xrs.service.forum.item.ForumGroupItem;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest;\nimport io.xeres.common.rest.forum.UpdateForumMessageReadRequest;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\n\nimport static io.xeres.common.rest.PathConfig.FORUMS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.*;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(ForumController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ForumControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = FORUMS_PATH;\n\n\t@MockitoBean\n\tprivate ForumRsService forumRsService;\n\n\t@MockitoBean\n\tprivate IdentityRsService identityRsService;\n\n\t@MockitoBean\n\tprivate IdentityService identityService;\n\n\t@MockitoBean\n\tprivate ForumMessageService forumMessageService;\n\n\t@MockitoBean\n\tprivate UnHtmlService unHtmlService;\n\n\t@Test\n\tvoid GetForumsGroups_Success() throws Exception\n\t{\n\t\tvar forumGroups = List.of(ForumGroupItemFakes.createForumGroupItem(), ForumGroupItemFakes.createForumGroupItem());\n\n\t\twhen(forumRsService.findAllGroups()).thenReturn(forumGroups);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(forumGroups.get(0).getId()), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\", is(forumGroups.get(0).getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.[1].id\").value(is(forumGroups.get(1).getId()), Long.class));\n\n\t\tverify(forumRsService).findAllGroups();\n\t}\n\n\t@Test\n\tvoid CreateForumGroup_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(forumRsService.createForumGroup(ownIdentity.getGxsId(), \"foo\", \"the best\")).thenReturn(1L);\n\n\t\tvar request = new CreateOrUpdateForumGroupRequest(\"foo\", \"the best\");\n\n\t\tmvc.perform(postJson(BASE_URL + \"/groups\", request))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + FORUMS_PATH + \"/groups/\" + 1L));\n\n\t\tverify(forumRsService).createForumGroup(eq(ownIdentity.getGxsId()), anyString(), anyString());\n\t}\n\n\t@Test\n\tvoid UpdateForumGroup_Success() throws Exception\n\t{\n\t\tvar request = new CreateOrUpdateForumGroupRequest(\"foo\", \"the best\");\n\n\t\tmvc.perform(putJson(BASE_URL + \"/groups/1\", request))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(forumRsService).updateForumGroup(1L, \"foo\", \"the best\");\n\t}\n\n\t@Test\n\tvoid GetForumByGroupId_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tvar forumGroupItem = new ForumGroupItem(null, \"foobar\");\n\n\t\twhen(forumRsService.findById(groupId)).thenReturn(Optional.of(forumGroupItem));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is(forumGroupItem.getId()), Long.class));\n\t}\n\n\t@Test\n\tvoid UpdateMessagesReadFlag_Success() throws Exception\n\t{\n\t\tvar request = new UpdateForumMessageReadRequest(1L, true);\n\n\t\tmvc.perform(patchJson(BASE_URL + \"/messages\", request))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(forumRsService).setMessageReadState(1L, true);\n\t}\n\n\t@Test\n\tvoid GetForumUnreadCount_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tint unreadCount = 5;\n\n\t\twhen(forumRsService.getUnreadCount(groupId)).thenReturn(unreadCount);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId + \"/unread-count\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.APPLICATION_JSON))\n\t\t\t\t.andExpect(content().string(String.valueOf(unreadCount)));\n\n\t\tverify(forumRsService).getUnreadCount(groupId);\n\t}\n\n\t@Test\n\tvoid SubscribeToForumGroup_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/groups/\" + groupId + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(forumRsService).subscribeToForumGroup(groupId);\n\t}\n\n\t@Test\n\tvoid MarkAllMessagesAsRead_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(put(BASE_URL + \"/groups/\" + groupId + \"/read?read=true\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(forumRsService).setAllGroupMessagesReadState(groupId, true);\n\t}\n\n\t@Test\n\tvoid UnsubscribeFromForumGroup_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\n\t\tmvc.perform(delete(BASE_URL + \"/groups/\" + groupId + \"/subscription\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(forumRsService).unsubscribeFromForumGroup(groupId);\n\t}\n\n\t@Test\n\tvoid GetForumMessages_Success() throws Exception\n\t{\n\t\tlong groupId = 1L;\n\t\tPage<ForumMessageItemSummary> forumMessages = new PageImpl<>(List.of(ForumMessageItemFakes.createForumMessageItemSummary(), ForumMessageItemFakes.createForumMessageItemSummary()));\n\n\t\twhen(forumRsService.findAllMessagesSummary(eq(groupId), any())).thenReturn(forumMessages);\n\t\twhen(forumMessageService.getAuthorsMapFromSummaries(forumMessages)).thenReturn(Map.of());\n\t\twhen(forumMessageService.getMessagesMapFromSummaries(groupId, forumMessages)).thenReturn(Map.of());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/groups/\" + groupId + \"/messages\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.content.size()\").value(is(forumMessages.getTotalElements()), Long.class));\n\n\t\tverify(forumRsService).findAllMessagesSummary(eq(groupId), any());\n\t\tverify(forumMessageService).getAuthorsMapFromSummaries(forumMessages);\n\t\tverify(forumMessageService).getMessagesMapFromSummaries(groupId, forumMessages);\n\t}\n\n\t@Test\n\tvoid GetForumMessage_Success() throws Exception\n\t{\n\t\tlong id = 1L;\n\t\tForumMessageItem forumMessage = ForumMessageItemFakes.createForumMessageItem();\n\n\t\twhen(forumRsService.findMessageById(id)).thenReturn(forumMessage);\n\t\twhen(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty());\n\t\twhen(forumRsService.findAllMessages(any(GxsId.class), anySet())).thenReturn(List.of());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/messages/\" + id))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is((int) forumMessage.getId())));\n\n\t\tverify(forumRsService).findMessageById(id);\n\t\tverify(identityService).findByGxsId(null);\n\t\tverify(forumRsService).findAllMessagesIncludingOlds(any(GxsId.class), anySet());\n\t}\n\n\t@Test\n\tvoid CreateForumMessage_Success() throws Exception\n\t{\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\t\twhen(forumRsService.createForumMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\tanyLong(),\n\t\t\t\tanyString(),\n\t\t\t\tanyString(),\n\t\t\t\tanyLong(),\n\t\t\t\tanyLong()\n\t\t)).thenReturn(1L);\n\n\t\tString requestBody = \"{\\\"forumId\\\":1,\\\"title\\\":\\\"Test Title\\\",\\\"content\\\":\\\"Test Content\\\"}\";\n\n\t\tmvc.perform(post(BASE_URL + \"/messages\")\n\t\t\t\t\t\t.contentType(MediaType.APPLICATION_JSON)\n\t\t\t\t\t\t.content(requestBody))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + FORUMS_PATH + \"/messages/\" + 1L));\n\n\t\tverify(forumRsService).createForumMessage(\n\t\t\t\teq(ownIdentity),\n\t\t\t\tanyLong(),\n\t\t\t\tanyString(),\n\t\t\t\tanyString(),\n\t\t\t\tanyLong(),\n\t\t\t\tanyLong()\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/geoip/GeoIpControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.geoip;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.service.GeoIpService;\nimport io.xeres.common.geoip.Country;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.Locale;\n\nimport static io.xeres.common.rest.PathConfig.GEOIP_PATH;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(GeoIpController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass GeoIpControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = GEOIP_PATH;\n\n\t@MockitoBean\n\tprivate GeoIpService geoIpService;\n\n\t@Test\n\tvoid GetIsoCountry_Success() throws Exception\n\t{\n\t\tvar address = \"1.1.1.1\";\n\n\t\twhen(geoIpService.getCountry(address)).thenReturn(Country.CH);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + address))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.isoCountry\").value(Country.CH.name().toLowerCase(Locale.ROOT)));\n\n\t\tverify(geoIpService).getCountry(address);\n\t}\n\n\t@Test\n\tvoid GetIsoCountry_Failure() throws Exception\n\t{\n\t\tvar address = \"1.1.1.1\";\n\n\t\twhen(geoIpService.getCountry(address)).thenReturn(null);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + address))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(geoIpService).getCountry(address);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/identity/IdentityControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.identity;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.gxs.IdentityGroupItemFakes;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.identicon.IdenticonService;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.List;\nimport java.util.NoSuchElementException;\nimport java.util.Objects;\nimport java.util.Optional;\n\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.http.HttpHeaders.CONTENT_TYPE;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(IdentityController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass IdentityControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = IDENTITIES_PATH;\n\n\t@MockitoBean\n\tprivate IdentityService identityService;\n\n\t@MockitoBean\n\tprivate IdentityRsService identityRsService;\n\n\t@MockitoBean\n\tprivate ContactNotificationService contactNotificationService;\n\n\t@MockitoBean\n\tprivate IdenticonService identiconService;\n\n\t@Test\n\tvoid FindIdentityById_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityService.findById(identity.getId())).thenReturn(Optional.of(identity));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + identity.getId()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is(identity.getId()), Long.class));\n\n\t\tverify(identityService).findById(identity.getId());\n\t}\n\n\t@Test\n\tvoid FindIdentityById_NotFound_Failure() throws Exception\n\t{\n\t\tvar id = 1L;\n\n\t\twhen(identityService.findById(id)).thenThrow(new NoSuchElementException());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + id))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(identityService).findById(id);\n\t}\n\n\t@Test\n\tvoid DownloadIdentityImage_Empty_Success() throws Exception\n\t{\n\t\tvar id = 1L;\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\n\t\twhen(identityService.findById(id)).thenReturn(Optional.of(identity));\n\t\twhen(identiconService.getIdenticon(any())).thenReturn(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream(\"/image/leguman.jpg\")).readAllBytes());\n\n\t\tmvc.perform(get(BASE_URL + \"/\" + id + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE));\n\n\t\tverify(identityService).findById(id);\n\t}\n\n\t@Test\n\tvoid DownloadIdentityImage_Success() throws Exception\n\t{\n\t\tvar id = 1L;\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setImage(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream(\"/image/leguman.jpg\")).readAllBytes());\n\n\t\twhen(identityService.findById(id)).thenReturn(Optional.of(identity));\n\n\t\tmvc.perform(get(BASE_URL + \"/\" + id + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE));\n\n\t\tverify(identityService).findById(id);\n\t}\n\n\t@Test\n\tvoid UploadIdentityImage_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityRsService.saveOwnIdentityImage(eq(identity.getId()), any())).thenReturn(identity);\n\n\t\tmvc.perform(post(BASE_URL + \"/\" + identity.getId() + \"/image\")\n\t\t\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t\t\t.accept(MediaType.APPLICATION_JSON)\n\t\t\t\t\t\t.content(\"\"))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + IDENTITIES_PATH + \"/\" + identity.getId() + \"/image\"));\n\n\t\tverify(identityRsService).saveOwnIdentityImage(eq(identity.getId()), any());\n\t}\n\n\t@Test\n\tvoid DeleteIdentityImage_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityRsService.deleteOwnIdentityImage(identity.getId())).thenReturn(identity);\n\n\t\tmvc.perform(delete(BASE_URL + \"/\" + identity.getId() + \"/image\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(identityRsService).deleteOwnIdentityImage(identity.getId());\n\t}\n\n\t@Test\n\tvoid FindIdentities_ByName_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityService.findAllByName(identity.getName())).thenReturn(List.of(identity));\n\n\t\tmvc.perform(getJson(BASE_URL + \"?name=\" + identity.getName()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(identity.getId()), Long.class));\n\n\t\tverify(identityService).findAllByName(identity.getName());\n\t}\n\n\t@Test\n\tvoid FindIdentities_ByGxsId_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityService.findByGxsId(identity.getGxsId())).thenReturn(Optional.of(identity));\n\n\t\tmvc.perform(getJson(BASE_URL + \"?gxsId=\" + identity.getGxsId()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(identity.getId()), Long.class));\n\n\t\tverify(identityService).findByGxsId(identity.getGxsId());\n\t}\n\n\t@Test\n\tvoid FindIdentities_ByType_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityService.findAllByType(identity.getType())).thenReturn(List.of(identity));\n\n\t\tmvc.perform(getJson(BASE_URL + \"?type=\" + identity.getType()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(identity.getId()), Long.class));\n\n\t\tverify(identityService).findAllByType(identity.getType());\n\t}\n\n\t@Test\n\tvoid FindIdentities_All_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identityService.getAll()).thenReturn(List.of(identity));\n\n\t\tmvc.perform(getJson(BASE_URL))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(identity.getId()), Long.class));\n\n\t\tverify(identityService).getAll();\n\t}\n\n\t@Test\n\tvoid DownloadImageByGxsId_Found_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\t\tidentity.setImage(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream(\"/image/leguman.jpg\")).readAllBytes());\n\n\t\twhen(identityService.findByGxsId(identity.getGxsId())).thenReturn(Optional.of(identity));\n\n\t\tmvc.perform(get(BASE_URL + \"/image\", MediaType.IMAGE_JPEG)\n\t\t\t\t\t\t.param(\"gxsId\", identity.getGxsId().toString())\n\t\t\t\t\t\t.param(\"find\", \"true\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE));\n\n\t\tverify(identityService).findByGxsId(identity.getGxsId());\n\t}\n\n\t@Test\n\tvoid DownloadImageByGxsId_Identicon_Success() throws Exception\n\t{\n\t\tvar identity = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tidentity.setId(1L);\n\n\t\twhen(identiconService.getIdenticon(any())).thenReturn(Objects.requireNonNull(IdentityControllerTest.class.getResourceAsStream(\"/image/leguman.jpg\")).readAllBytes());\n\n\t\tmvc.perform(get(BASE_URL + \"/image\", MediaType.IMAGE_JPEG)\n\t\t\t\t\t\t.param(\"gxsId\", identity.getGxsId().toString()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(header().string(CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE));\n\n\t\tverify(identiconService).getIdenticon(any());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/location/LocationControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.location;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.api.converter.BufferedImageConverter;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.QrCodeService;\nimport io.xeres.common.rsid.Type;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.context.annotation.Import;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport javax.imageio.ImageIO;\nimport java.io.ByteArrayInputStream;\nimport java.util.Objects;\nimport java.util.Optional;\n\nimport static io.xeres.common.rest.PathConfig.LOCATIONS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.http.HttpHeaders.CONTENT_TYPE;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(LocationController.class)\n@AutoConfigureMockMvc(addFilters = false)\n@Import(BufferedImageConverter.class) // @Components aren't imported by default by @WebMvcTest\nclass LocationControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = LOCATIONS_PATH;\n\n\t@MockitoBean\n\tprivate LocationService locationService;\n\n\t@MockitoBean\n\tprivate QrCodeService qrCodeService;\n\n\t@Test\n\tvoid FindLocationById_Success() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + location.getId()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is(location.getId()), Long.class));\n\n\t\tverify(locationService).findLocationById(location.getId());\n\t}\n\n\t@Test\n\tvoid GetRSIdOfLocation_Success() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + location.getId() + \"/rs-id\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.name\", is(location.getProfile().getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.location\", is(location.getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.rsId\", is(location.getRsId(Type.ANY).getArmored())));\n\n\t\tverify(locationService).findLocationById(location.getId());\n\t}\n\n\t@Test\n\tvoid GetRSIdOfLocation_QrCode_Success() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\tvar rsId = location.getRsId(Type.SHORT_INVITE).getArmored();\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\t\twhen(qrCodeService.generateQrCode(rsId)).thenReturn(ImageIO.read(new ByteArrayInputStream(Objects.requireNonNull(LocationControllerTest.class.getResourceAsStream(\"/image/abitbol.png\")).readAllBytes())));\n\n\t\tmvc.perform(get(BASE_URL + \"/\" + location.getId() + \"/rs-id/qr-code\", MediaType.IMAGE_PNG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(header().string(CONTENT_TYPE, \"image/png\"));\n\n\t\tverify(locationService).findLocationById(location.getId());\n\t\tverify(qrCodeService).generateQrCode(rsId);\n\t}\n\n\t@Test\n\tvoid IsServiceSupported_ReturnsOk() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\tint serviceId = 42;\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\t\twhen(locationService.isServiceSupported(location, serviceId)).thenReturn(true);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + location.getId() + \"/service/\" + serviceId))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(locationService).findLocationById(location.getId());\n\t\tverify(locationService).isServiceSupported(location, serviceId);\n\t}\n\n\t@Test\n\tvoid IsServiceSupported_ReturnsNotFound() throws Exception\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\t\tint serviceId = 99;\n\n\t\twhen(locationService.findLocationById(location.getId())).thenReturn(Optional.of(location));\n\t\twhen(locationService.isServiceSupported(location, serviceId)).thenReturn(false);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + location.getId() + \"/service/\" + serviceId))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(locationService).findLocationById(location.getId());\n\t\tverify(locationService).isServiceSupported(location, serviceId);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/notification/NotificationControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.notification;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.service.notification.availability.AvailabilityNotificationService;\nimport io.xeres.app.service.notification.board.BoardNotificationService;\nimport io.xeres.app.service.notification.channel.ChannelNotificationService;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.app.service.notification.file.FileNotificationService;\nimport io.xeres.app.service.notification.file.FileSearchNotificationService;\nimport io.xeres.app.service.notification.file.FileTrendNotificationService;\nimport io.xeres.app.service.notification.forum.ForumNotificationService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.web.servlet.mvc.method.annotation.SseEmitter;\n\nimport static io.xeres.common.rest.PathConfig.NOTIFICATIONS_PATH;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(NotificationController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass NotificationControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = NOTIFICATIONS_PATH;\n\n\t@MockitoBean\n\tprivate StatusNotificationService statusNotificationService;\n\n\t@MockitoBean\n\tprivate ForumNotificationService forumNotificationService;\n\n\t@MockitoBean\n\tprivate FileNotificationService fileNotificationService;\n\n\t@MockitoBean\n\tprivate FileSearchNotificationService fileSearchNotificationService;\n\n\t@MockitoBean\n\tprivate ContactNotificationService contactNotificationService;\n\n\t@MockitoBean\n\tprivate AvailabilityNotificationService availabilityNotificationService;\n\n\t@MockitoBean\n\tprivate FileTrendNotificationService fileTrendNotificationService;\n\n\t@MockitoBean\n\tprivate BoardNotificationService boardNotificationService;\n\n\t@MockitoBean\n\tprivate ChannelNotificationService channelNotificationService;\n\n\t@Test\n\tvoid SetupStatusNotification_Success() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(statusNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/status\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupForumNotification_Success() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(forumNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/forum\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupBoardNotification_Success() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(boardNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/board\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupChannelNotification_Success() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(channelNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/channel\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupFileNotification_Successs() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(fileNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/file\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupFileSearchNotification_Successs() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(fileSearchNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/file-search\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupContactNotification_Successs() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(contactNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/contact\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupAvailabilityNotification_Successs() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(availabilityNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/availability\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n\n\t@Test\n\tvoid SetupFileTrendNotification_Success() throws Exception\n\t{\n\t\tvar sseEmitter = new SseEmitter();\n\n\t\twhen(fileTrendNotificationService.addClient()).thenReturn(sseEmitter);\n\n\t\tmvc.perform(get(BASE_URL + \"/file-trend\", MediaType.TEXT_EVENT_STREAM))\n\t\t\t\t.andExpect(status().isOk());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/profile/ProfileControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.profile;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.crypto.rsid.RSId;\nimport io.xeres.app.crypto.rsid.RSIdFakes;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.job.PeerConnectionJob;\nimport io.xeres.app.service.ContactService;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.identicon.IdenticonService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.rest.profile.ProfileKeyAttributes;\nimport io.xeres.common.rest.profile.RsIdRequest;\nimport org.bouncycastle.util.encoders.Base64;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.*;\n\nimport static io.xeres.common.rest.PathConfig.PROFILES_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.Mockito.*;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;\n\n@WebMvcTest(ProfileController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ProfileControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = PROFILES_PATH;\n\n\t@SuppressWarnings(\"unused\")\n\t@MockitoBean\n\tprivate PeerConnectionJob peerConnectionJob;\n\n\t@MockitoBean\n\tprivate ProfileService profileService;\n\n\t@MockitoBean\n\tprivate IdentityService identityService;\n\n\t@MockitoBean\n\tprivate IdenticonService identiconService;\n\n\t@MockitoBean\n\tprivate LocationService locationService;\n\n\t@MockitoBean\n\tprivate ContactService contactService;\n\n\t@SuppressWarnings(\"unused\")\n\t@MockitoBean\n\tprivate StatusNotificationService statusNotificationService;\n\n\t@Test\n\tvoid FindProfileById_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\n\t\twhen(profileService.findProfileById(expected.getId())).thenReturn(Optional.of(expected));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + expected.getId()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.id\").value(is(expected.getId()), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.name\", is(expected.getName())))\n\t\t\t\t.andExpect(jsonPath(\"$.pgpFingerprint\", is(Base64.toBase64String(expected.getProfileFingerprint().getBytes()))))\n\t\t\t\t.andExpect(jsonPath(\"$.pgpPublicKeyData\", is(Base64.toBase64String(expected.getPgpPublicKeyData()))))\n\t\t\t\t.andExpect(jsonPath(\"$.accepted\").value(is(expected.isAccepted()), Boolean.class))\n\t\t\t\t.andExpect(jsonPath(\"$.trust\", is(expected.getTrust().name())));\n\n\t\tverify(profileService).findProfileById(expected.getId());\n\t}\n\n\t@Test\n\tvoid FindProfileById_NotFound() throws Exception\n\t{\n\t\tvar id = 2L;\n\n\t\twhen(profileService.findProfileById(id)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + id))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(profileService).findProfileById(id);\n\t}\n\n\t@Test\n\tvoid FindProfileByName_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\n\t\twhen(profileService.findProfilesByName(expected.getName())).thenReturn(List.of(expected));\n\n\t\tmvc.perform(getJson(BASE_URL + \"?name=\" + expected.getName()))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(expected.getId()), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\", is(expected.getName())));\n\n\t\tverify(profileService).findProfilesByName(expected.getName());\n\t}\n\n\t@Test\n\tvoid FindProfileByName_NotFound() throws Exception\n\t{\n\t\tvar name = \"inexistant\";\n\n\t\twhen(profileService.findProfilesByName(name)).thenReturn(Collections.emptyList());\n\n\t\tmvc.perform(getJson(BASE_URL + \"?name=\" + name))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().string(\"[]\"));\n\n\t\tverify(profileService).findProfilesByName(name);\n\t}\n\n\t@Test\n\tvoid FindProfileByLocationIdentifier_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\texpected.addLocation(LocationFakes.createLocation(\"test\", expected));\n\t\tvar locationIdentifier = expected.getLocations().getFirst().getLocationIdentifier();\n\n\t\twhen(profileService.findProfileByLocationIdentifier(locationIdentifier)).thenReturn(Optional.of(expected));\n\n\t\tmvc.perform(getJson(BASE_URL + \"?locationIdentifier=\" + locationIdentifier))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(expected.getId()), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\", is(expected.getName())));\n\n\t\tverify(profileService).findProfileByLocationIdentifier(locationIdentifier);\n\t}\n\n\t@Test\n\tvoid FindProfiles_Success() throws Exception\n\t{\n\t\tvar profile1 = ProfileFakes.createProfile(\"test1\", 1);\n\t\tvar profile2 = ProfileFakes.createProfile(\"test2\", 2);\n\t\tvar profiles = List.of(profile1, profile2);\n\n\t\twhen(profileService.getAllProfiles()).thenReturn(profiles);\n\n\t\tmvc.perform(getJson(BASE_URL))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].id\").value(is(profiles.getFirst().getId()), Long.class));\n\n\t\tverify(profileService).getAllProfiles();\n\t}\n\n\t@Test\n\tvoid CreateProfile_ShortInvite_WithTrustAndConnectionIndex_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\texpected.addLocation(LocationFakes.createLocation(\"test\", expected));\n\t\tvar profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored());\n\n\t\twhen(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected);\n\t\twhen(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected);\n\n\t\tmvc.perform(postJson(BASE_URL + \"?trust=FULL&connectionIndex=1\", profileRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + PROFILES_PATH + \"/\" + expected.getId()));\n\n\t\tverify(profileService).createOrUpdateProfile(any(Profile.class));\n\t\tverify(peerConnectionJob).connectImmediately(expected.getLocations().getFirst(), 1);\n\t}\n\n\t@Test\n\tvoid CreateProfile_ShortInvite_WithTrustInMixedCaseAndConnectionIndex_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\texpected.addLocation(LocationFakes.createLocation(\"test\", expected));\n\t\tvar profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored());\n\n\t\twhen(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected);\n\t\twhen(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected);\n\n\t\tmvc.perform(postJson(BASE_URL + \"?trust=Full&connectionIndex=1\", profileRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + PROFILES_PATH + \"/\" + expected.getId()));\n\n\t\tverify(profileService).createOrUpdateProfile(any(Profile.class));\n\t\tverify(peerConnectionJob).connectImmediately(expected.getLocations().getFirst(), 1);\n\t}\n\n\t@Test\n\tvoid CreateProfile_ShortInvite_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\tvar profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored());\n\n\t\twhen(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected);\n\t\twhen(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected);\n\n\t\tmvc.perform(postJson(BASE_URL, profileRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + PROFILES_PATH + \"/\" + expected.getId()));\n\n\t\tverify(profileService).createOrUpdateProfile(any(Profile.class));\n\t}\n\n\t@Test\n\tvoid CreateProfile_RsCertificate_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"Nemesis\", 0x9F00B21277698D8DL, Id.toBytes(\"60049f670534eab17dda2e6d9f00b21277698d8d\"), Id.toBytes(\"984d0461fd80400102008e20511e623f662693d054e1aeb26a007e17f745d4616a6a647d22313b67111ce5f45db22fb670bb5e05f4846ad6d686224acc22966f28e1a50d99d4afb295fb0011010001b4084e656d6573697320885c041001020006050261fd8040000a09109f00b21277698d8d97e401ff688d2b9b73551587858994309485909a36b5401518716698131e1811d8f8204348392c89e99fcb21651d7490e9877b80ced7e11aabbb7c0538853954d77d047b\"));\n\t\tvar profileRequest = new RsIdRequest(RSIdFakes.createRsCertificate(expected).getArmored());\n\n\t\twhen(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected);\n\t\twhen(profileService.createOrUpdateProfile(any(Profile.class))).thenReturn(expected);\n\n\t\tmvc.perform(postJson(BASE_URL, profileRequest))\n\t\t\t\t.andExpect(status().isCreated())\n\t\t\t\t.andExpect(header().string(\"Location\", \"http://localhost\" + PROFILES_PATH + \"/\" + expected.getId()));\n\n\t\tverify(profileService).createOrUpdateProfile(any(Profile.class));\n\t}\n\n\t@Test\n\tvoid CreateProfile_MissingCertificate_BadRequest() throws Exception\n\t{\n\t\t@SuppressWarnings(\"DataFlowIssue\") var profileRequest = new RsIdRequest(null);\n\n\t\tmvc.perform(postJson(BASE_URL, profileRequest))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid CreateProfile_BrokenCertificate_BadRequest() throws Exception\n\t{\n\t\tvar profileRequest = new RsIdRequest(\"foo\");\n\n\t\tmvc.perform(postJson(BASE_URL, profileRequest))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid CreateProfile_IllegalTrust_BadRequest() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\tvar profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored());\n\n\t\twhen(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected);\n\n\t\tmvc.perform(postJson(BASE_URL + \"?trust=ULTIMATE\", profileRequest))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid DeleteProfile_Success() throws Exception\n\t{\n\t\tlong id = 2;\n\n\t\tmvc.perform(delete(BASE_URL + \"/\" + id))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(profileService).deleteProfile(id);\n\t}\n\n\t@Test\n\tvoid DeleteProfile_NotFound() throws Exception\n\t{\n\t\tlong id = 2;\n\n\t\tdoThrow(NoSuchElementException.class).when(profileService).deleteProfile(id);\n\n\t\tmvc.perform(delete(BASE_URL + \"/\" + id))\n\t\t\t\t.andExpect(status().isNotFound());\n\n\t\tverify(profileService).deleteProfile(id);\n\t}\n\n\t@Test\n\tvoid DeleteProfile_Own_UnprocessableEntity() throws Exception\n\t{\n\t\tlong id = 1;\n\n\t\tmvc.perform(delete(BASE_URL + \"/\" + id))\n\t\t\t\t.andExpect(status().isUnprocessableContent());\n\t}\n\n\t@Test\n\tvoid FindProfileKeyAttributes_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\tvar keyAttributes = mock(ProfileKeyAttributes.class);\n\n\t\twhen(profileService.findProfileById(expected.getId())).thenReturn(Optional.of(expected));\n\t\twhen(profileService.findProfileKeyAttributes(expected.getId())).thenReturn(keyAttributes);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + expected.getId() + \"/key-attributes\"))\n\t\t\t\t.andExpect(status().isOk());\n\n\t\tverify(profileService).findProfileKeyAttributes(expected.getId());\n\t}\n\n\t@Test\n\tvoid FindContactsForProfile_Success() throws Exception\n\t{\n\t\twhen(contactService.getContactsForProfileId(1L)).thenReturn(List.of(new Contact(\"foo\", 1L, 1L, Availability.AVAILABLE, true)));\n\n\t\tmvc.perform(getJson(BASE_URL + \"/1/contacts\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.[0].profileId\").value(is(1L), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].identityId\").value(is(1L), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].availability\").value(is(\"AVAILABLE\"), String.class))\n\t\t\t\t.andExpect(jsonPath(\"$.[0].name\", is(\"foo\")));\n\n\t\tverify(contactService).getContactsForProfileId(1L);\n\t}\n\n\t@Test\n\tvoid FindProfileKeyAttributes_NotFound() throws Exception\n\t{\n\t\tvar id = 2L;\n\n\t\twhen(profileService.findProfileKeyAttributes(id)).thenThrow(new NoSuchElementException());\n\n\t\tmvc.perform(getJson(BASE_URL + \"/\" + id + \"/key-attributes\"))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid DownloadImage_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\n\t\twhen(profileService.findProfileById(expected.getId())).thenReturn(Optional.of(expected));\n\t\twhen(identiconService.getIdenticon(any())).thenReturn(Objects.requireNonNull(ProfileControllerTest.class.getResourceAsStream(\"/image/leguman.jpg\")).readAllBytes());\n\n\t\tmvc.perform(get(BASE_URL + \"/\" + expected.getId() + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(content().contentType(MediaType.IMAGE_JPEG));\n\n\t\tverify(identiconService).getIdenticon(any());\n\t}\n\n\t@Test\n\tvoid DownloadImage_NotFound() throws Exception\n\t{\n\t\tvar id = 2L;\n\n\t\twhen(profileService.findProfileById(id)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(get(BASE_URL + \"/\" + id + \"/image\", MediaType.IMAGE_JPEG))\n\t\t\t\t.andExpect(status().isNotFound());\n\t}\n\n\t@Test\n\tvoid CheckProfileFromRsId_Success() throws Exception\n\t{\n\t\tvar expected = ProfileFakes.createProfile(\"test\", 1);\n\t\tvar profileRequest = new RsIdRequest(RSIdFakes.createShortInvite().getArmored());\n\n\t\twhen(profileService.getProfileFromRSId(any(RSId.class))).thenReturn(expected);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/check\", profileRequest))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.name\", is(expected.getName())));\n\n\t\tverify(profileService).getProfileFromRSId(any());\n\t}\n\n\t@Test\n\tvoid CheckProfileFromRsId_Invalid() throws Exception\n\t{\n\t\tvar profileRequest = new RsIdRequest(\"invalid id\");\n\n\t\tmvc.perform(postJson(BASE_URL + \"/check\", profileRequest))\n\t\t\t\t.andExpect(status().isUnprocessableContent());\n\t}\n\n\t@Test\n\tvoid CheckProfileFromRsId_TooShort_BadRequest() throws Exception\n\t{\n\t\tvar profileRequest = new RsIdRequest(\"invalid\");\n\n\t\tmvc.perform(postJson(BASE_URL + \"/check\", profileRequest))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid SetTrust_Success() throws Exception\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"test\", 2);\n\n\t\twhen(profileService.findProfileById(profile.getId())).thenReturn(Optional.of(profile));\n\n\t\tmvc.perform(putJson(BASE_URL + \"/\" + profile.getId() + \"/trust\", \"MARGINAL\"))\n\t\t\t\t.andExpect(status().isNoContent());\n\n\t\tverify(profileService).createOrUpdateProfile(profile);\n\t}\n\n\t@Test\n\tvoid SetTrust_OwnProfile_BadRequest() throws Exception\n\t{\n\t\tvar profile = ProfileFakes.createOwnProfile();\n\n\t\twhen(profileService.findProfileById(profile.getId())).thenReturn(Optional.of(profile));\n\n\t\tmvc.perform(putJson(BASE_URL + \"/\" + profile.getId() + \"/trust\", \"MARGINAL\"))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid SetTrust_Ultimate_BadRequest() throws Exception\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"test\", 2);\n\n\t\twhen(profileService.findProfileById(profile.getId())).thenReturn(Optional.of(profile));\n\n\t\tmvc.perform(putJson(BASE_URL + \"/\" + profile.getId() + \"/trust\", \"ULTIMATE\"))\n\t\t\t\t.andExpect(status().isBadRequest());\n\t}\n\n\t@Test\n\tvoid SetTrust_NotFound() throws Exception\n\t{\n\t\tvar id = 2L;\n\n\t\twhen(profileService.findProfileById(id)).thenReturn(Optional.empty());\n\n\t\tmvc.perform(putJson(BASE_URL + \"/\" + id + \"/trust\", \"MARGINAL\"))\n\t\t\t\t.andExpect(status().isUnprocessableContent());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/settings/SettingsControllerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.settings;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.settings.SettingsFakes;\nimport io.xeres.app.database.model.settings.SettingsMapper;\nimport io.xeres.app.service.SettingsService;\nimport jakarta.json.Json;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport static io.xeres.common.rest.PathConfig.SETTINGS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(SettingsController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass SettingsControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = SETTINGS_PATH;\n\n\t@MockitoBean\n\tprivate SettingsService settingsService;\n\n\t@Test\n\tvoid GetSettings_Success() throws Exception\n\t{\n\t\tvar settings = SettingsFakes.createSettings();\n\n\t\twhen(settingsService.getSettings()).thenReturn(SettingsMapper.toDTO(settings));\n\n\t\tmvc.perform(getJson(BASE_URL))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.dhtEnabled\", is(settings.isDhtEnabled())));\n\t}\n\n\t@Test\n\tvoid UpdateSettings_Success() throws Exception\n\t{\n\t\tvar settings = SettingsFakes.createSettings();\n\n\t\twhen(settingsService.applyPatchToSettings(any())).thenReturn(settings);\n\n\t\tvar patch = Json.createPatchBuilder().build();\n\n\t\tmvc.perform(patchJson(BASE_URL, patch))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.dhtEnabled\", is(settings.isDhtEnabled())));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/share/ShareControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.share;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.database.model.file.FileFakes;\nimport io.xeres.app.database.model.share.Share;\nimport io.xeres.app.service.file.FileService;\nimport io.xeres.common.dto.share.ShareDTO;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.rest.share.TemporaryShareRequest;\nimport io.xeres.common.rest.share.UpdateShareRequest;\nimport io.xeres.testutils.Sha1SumFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.nio.file.Path;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\n\nimport static io.xeres.common.rest.PathConfig.SHARES_PATH;\nimport static org.hamcrest.Matchers.hasSize;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(ShareController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass ShareControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = SHARES_PATH;\n\n\t@MockitoBean\n\tprivate FileService fileService;\n\n\t@Test\n\tvoid GetShares_Success() throws Exception\n\t{\n\t\tvar share = Share.createShare(\"foo\", FileFakes.createFile(\"test\"), true, Trust.FULL);\n\t\tshare.setId(1L);\n\n\t\twhen(fileService.getShares()).thenReturn(List.of(share));\n\t\twhen(fileService.getFilesMapFromShares(any())).thenReturn(Map.of(share.getId(), \"foo/bar\"));\n\n\t\tmvc.perform(getJson(BASE_URL))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$\", hasSize(1)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].id\", is(1)))\n\t\t\t\t.andExpect(jsonPath(\"$[0].path\", is(\"foo/bar\")));\n\n\t\tverify(fileService).getShares();\n\t\tverify(fileService).getFilesMapFromShares(any());\n\t}\n\n\t@Test\n\tvoid CreateAndUpdateShares_Success() throws Exception\n\t{\n\t\tvar shareDTO = new ShareDTO(1L, \"foo\", \"foo/bar\", true, Trust.FULL, Instant.now());\n\t\tvar updateRequest = new UpdateShareRequest(List.of(shareDTO));\n\n\t\tmvc.perform(postJson(BASE_URL, updateRequest))\n\t\t\t\t.andExpect(status().isCreated());\n\n\t\tverify(fileService).synchronize(any());\n\t}\n\n\t@Test\n\tvoid ShareTemporarily_Success() throws Exception\n\t{\n\t\tvar filePath = \"/tmp/test.txt\";\n\t\tvar temporaryShareRequest = new TemporaryShareRequest(filePath);\n\t\tvar path = Path.of(filePath);\n\t\tvar hash = Sha1SumFakes.createSha1Sum();\n\n\t\twhen(fileService.calculateTemporaryFileHash(path)).thenReturn(hash);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/temporary\", temporaryShareRequest))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.hash\", is(hash.toString())));\n\n\t\tverify(fileService).calculateTemporaryFileHash(path);\n\t}\n\n\t@Test\n\tvoid ShareTemporarily_HashCalculationFails() throws Exception\n\t{\n\t\tvar filePath = \"/tmp/test.txt\";\n\t\tvar temporaryShareRequest = new TemporaryShareRequest(filePath);\n\t\tvar path = Path.of(filePath);\n\n\t\twhen(fileService.calculateTemporaryFileHash(path)).thenReturn(null);\n\n\t\tmvc.perform(postJson(BASE_URL + \"/temporary\", temporaryShareRequest))\n\t\t\t\t.andExpect(status().isInternalServerError());\n\n\t\tverify(fileService).calculateTemporaryFileHash(path);\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/statistics/StatisticsControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.statistics;\n\nimport io.xeres.app.api.controller.AbstractControllerTest;\nimport io.xeres.app.xrs.service.bandwidth.BandwidthRsService;\nimport io.xeres.app.xrs.service.rtt.RttRsService;\nimport io.xeres.app.xrs.service.turtle.TurtleRsService;\nimport io.xeres.app.xrs.service.turtle.TurtleStatistics;\nimport io.xeres.common.rest.statistics.DataCounterPeer;\nimport io.xeres.common.rest.statistics.DataCounterStatisticsResponse;\nimport io.xeres.common.rest.statistics.RttPeer;\nimport io.xeres.common.rest.statistics.RttStatisticsResponse;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;\nimport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\n\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.STATISTICS_PATH;\nimport static org.hamcrest.Matchers.is;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@WebMvcTest(StatisticsController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass StatisticsControllerTest extends AbstractControllerTest\n{\n\tprivate static final String BASE_URL = STATISTICS_PATH;\n\n\t@MockitoBean\n\tprivate TurtleRsService turtleRsService;\n\n\t@MockitoBean\n\tprivate RttRsService rttRsService;\n\n\t@MockitoBean\n\tprivate BandwidthRsService bandwidthRsService;\n\n\t@Test\n\tvoid GetTurtleStatistics_Success() throws Exception\n\t{\n\t\tvar stats = new TurtleStatistics();\n\t\tstats.addToDataDownload(5);\n\t\twhen(turtleRsService.getStatistics()).thenReturn(stats);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/turtle\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.dataDownload\").value(is(5.0f), Float.class));\n\n\t\tverify(turtleRsService).getStatistics();\n\t}\n\n\t@Test\n\tvoid GetRttStatistics_Success() throws Exception\n\t{\n\t\tvar rttPeer = new RttPeer(1L, \"foo\", 2);\n\t\tvar stats = new RttStatisticsResponse(List.of(rttPeer));\n\t\twhen(rttRsService.getStatistics()).thenReturn(stats);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/rtt\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].id\").value(is(1L), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].name\").value(is(\"foo\"), String.class))\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].mean\").value(is(2L), Long.class));\n\n\t\tverify(rttRsService).getStatistics();\n\t}\n\n\t@Test\n\tvoid GetDataCounterStatistics_Success() throws Exception\n\t{\n\t\tvar dataCounterPeer = new DataCounterPeer(1L, \"foo\", 2L, 3L);\n\t\tvar stats = new DataCounterStatisticsResponse(List.of(dataCounterPeer));\n\t\twhen(bandwidthRsService.getDataCounterStatistics()).thenReturn(stats);\n\n\t\tmvc.perform(getJson(BASE_URL + \"/data-counter\"))\n\t\t\t\t.andExpect(status().isOk())\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].id\").value(is(1L), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].name\").value(is(\"foo\"), String.class))\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].sent\").value(is(2L), Long.class))\n\t\t\t\t.andExpect(jsonPath(\"$.peers.[0].received\").value(is(3L), Long.class));\n\n\t\tverify(bandwidthRsService).getDataCounterStatistics();\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/api/controller/voip/VoipMessageControllerTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.api.controller.voip;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.xrs.service.voip.VoipRsService;\nimport io.xeres.common.message.voip.VoipAction;\nimport io.xeres.common.message.voip.VoipMessage;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass VoipMessageControllerTest\n{\n\tprivate static final String DESTINATION_ID = LocationFakes.createLocation().getLocationIdentifier().toString();\n\n\t@Mock\n\tprivate VoipRsService voipRsService;\n\n\t@Test\n\tvoid processPrivateVoipMessageFromProducer_callsCallOnRing()\n\t{\n\t\tvar controller = new VoipMessageController(voipRsService);\n\t\tvar msg = new VoipMessage(VoipAction.RING);\n\n\t\tcontroller.processPrivateVoipMessageFromProducer(DESTINATION_ID, msg);\n\n\t\tverify(voipRsService, times(1)).call(any());\n\t\tverifyNoMoreInteractions(voipRsService);\n\t}\n\n\t@Test\n\tvoid processPrivateVoipMessageFromProducer_callsAcceptOnAcknowledge()\n\t{\n\t\tvar controller = new VoipMessageController(voipRsService);\n\t\tvar msg = new VoipMessage(VoipAction.ACKNOWLEDGE);\n\n\t\tcontroller.processPrivateVoipMessageFromProducer(DESTINATION_ID, msg);\n\n\t\tverify(voipRsService, times(1)).accept(any());\n\t\tverifyNoMoreInteractions(voipRsService);\n\t}\n\n\t@Test\n\tvoid processPrivateVoipMessageFromProducer_callsHangupOnClose()\n\t{\n\t\tvar controller = new VoipMessageController(voipRsService);\n\t\tvar msg = new VoipMessage(VoipAction.CLOSE);\n\n\t\tcontroller.processPrivateVoipMessageFromProducer(DESTINATION_ID, msg);\n\n\t\tverify(voipRsService, times(1)).hangup(any());\n\t\tverifyNoMoreInteractions(voipRsService);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/application/SingleInstanceRunTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass SingleInstanceRunTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(SingleInstanceRun.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/application/autostart/AutoStartTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.autostart;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass AutoStartTest\n{\n\t@Mock\n\tprivate AutoStarter autoStarter;\n\n\t@InjectMocks\n\tprivate AutoStart autoStart;\n\n\t@Test\n\tvoid Enable_Supported_Success()\n\t{\n\t\twhen(autoStarter.isSupported()).thenReturn(true);\n\n\t\tautoStart.enable();\n\n\t\tverify(autoStarter).enable();\n\t}\n\n\t@Test\n\tvoid Enable_NotSupported_NoOp()\n\t{\n\t\twhen(autoStarter.isSupported()).thenReturn(false);\n\n\t\tautoStart.enable();\n\n\t\tverify(autoStarter, never()).enable();\n\t}\n\n\t@Test\n\tvoid Disable_Supported_Success()\n\t{\n\t\twhen(autoStarter.isSupported()).thenReturn(true);\n\n\t\tautoStart.disable();\n\n\t\tverify(autoStarter).disable();\n\t}\n\n\t@Test\n\tvoid Disable_NotSupported_NoOp()\n\t{\n\t\twhen(autoStarter.isSupported()).thenReturn(false);\n\n\t\tautoStart.disable();\n\n\t\tverify(autoStarter, never()).disable();\n\t}\n\n\t@Test\n\tvoid IsEnabled_Supported_Success()\n\t{\n\t\twhen(autoStarter.isSupported()).thenReturn(true);\n\t\twhen(autoStarter.isEnabled()).thenReturn(true);\n\n\t\tvar enabled = autoStart.isEnabled();\n\n\t\tassertTrue(enabled);\n\n\t\tverify(autoStarter).isEnabled();\n\t}\n\n\t@Test\n\tvoid IsEnabled_NotSupported_False()\n\t{\n\t\twhen(autoStarter.isSupported()).thenReturn(false);\n\n\t\tvar enabled = autoStart.isEnabled();\n\n\t\tassertFalse(enabled);\n\n\t\tverify(autoStarter, never()).isEnabled();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/application/autostart/autostarter/AutoStarterGenericTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.autostart.autostarter;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass AutoStarterGenericTest\n{\n\tprivate static AutoStarterGeneric autoStarterGeneric;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tautoStarterGeneric = new AutoStarterGeneric();\n\t}\n\n\t@Test\n\tvoid isSupported()\n\t{\n\t\tassertFalse(autoStarterGeneric.isSupported());\n\t}\n\n\t@Test\n\tvoid isEnabled()\n\t{\n\t\tassertThrows(UnsupportedOperationException.class, () -> autoStarterGeneric.isEnabled());\n\t}\n\n\t@Test\n\tvoid enable()\n\t{\n\t\tassertThrows(UnsupportedOperationException.class, () -> autoStarterGeneric.enable());\n\t}\n\n\t@Test\n\tvoid disable()\n\t{\n\t\tassertThrows(UnsupportedOperationException.class, () -> autoStarterGeneric.disable());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/application/environment/DefaultPropertiesTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.application.environment;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass DefaultPropertiesTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(DefaultProperties.class);\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/configuration/DataDirConfigurationTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.configuration;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.core.env.Environment;\n\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass DataDirConfigurationTest\n{\n\t@Mock\n\tprivate Environment environment;\n\n\t@InjectMocks\n\tprivate DataDirConfiguration dataDirConfiguration;\n\n\t@Test\n\tvoid GetDataDir_DataSourceAlreadySet_Success()\n\t{\n\t\twhen(environment.getProperty(\"spring.datasource.url\")).thenReturn(\"something\");\n\n\t\tvar dataDir = dataDirConfiguration.getDataDir();\n\n\t\tassertNull(dataDir);\n\t}\n\n\t// Any tests that creates data dirs and so on don't work well with CI\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/aead/AEADTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.aead;\n\nimport io.xeres.testutils.TestUtils;\nimport org.apache.commons.lang3.RandomUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport javax.crypto.SecretKey;\nimport java.nio.charset.StandardCharsets;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass AEADTest\n{\n\tprivate static SecretKey key;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tkey = AEAD.generateKey();\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(AEAD.class);\n\t}\n\n\t@Test\n\tvoid EncryptChaCha20Poly1305_DecryptChaCha20Poly1305_Success()\n\t{\n\t\tvar nonce = RandomUtils.insecure().randomBytes(12);\n\t\tvar plainText = \"hello world\".getBytes(StandardCharsets.UTF_8);\n\t\tvar aad = RandomUtils.insecure().randomBytes(16);\n\n\t\tvar cipherText = AEAD.encryptChaCha20Poly1305(key, nonce, plainText, aad);\n\t\tvar decryptedText = AEAD.decryptChaCha20Poly1305(key, nonce, cipherText, aad);\n\n\t\tassertArrayEquals(plainText, decryptedText);\n\t}\n\n\t@Test\n\tvoid EncryptChaCha20Poly1305_DecryptChaCha20Poly1305_BadNonce()\n\t{\n\t\tvar nonce = RandomUtils.insecure().randomBytes(8);\n\t\tvar plainText = \"hello world\".getBytes(StandardCharsets.UTF_8);\n\t\tvar aad = RandomUtils.insecure().randomBytes(16);\n\n\t\tassertThrows(IllegalArgumentException.class, () -> AEAD.encryptChaCha20Poly1305(key, nonce, plainText, aad));\n\t}\n\n\t@Test\n\tvoid EncryptChaCha20Aes256_DecryptChaCha20Aes256_Success()\n\t{\n\t\tvar nonce = RandomUtils.insecure().randomBytes(12);\n\t\tvar plainText = \"hello world\".getBytes(StandardCharsets.UTF_8);\n\t\tvar aad = RandomUtils.insecure().randomBytes(16);\n\n\t\tvar cipherText = AEAD.encryptChaCha20Sha256(key, nonce, plainText, aad);\n\t\tvar decryptedText = AEAD.decryptChaCha20Sha256(key, nonce, cipherText, aad);\n\n\t\tassertArrayEquals(plainText, decryptedText);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/aes/AESTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.aes;\n\nimport io.xeres.app.crypto.hash.sha1.Sha1MessageDigest;\nimport io.xeres.testutils.TestUtils;\nimport org.apache.commons.lang3.RandomUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.nio.charset.StandardCharsets;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass AESTest\n{\n\tprivate static final byte[] aesKey = new byte[16];\n\tprivate static byte[] iv;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tSha1MessageDigest digest = new Sha1MessageDigest();\n\t\tdigest.update(RandomUtils.insecure().randomBytes(16));\n\t\tSystem.arraycopy(digest.getBytes(), 0, aesKey, 0, aesKey.length);\n\n\t\tiv = RandomUtils.insecure().randomBytes(8);\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(AES.class);\n\t}\n\n\t@Test\n\tvoid Encrypt_AES_Success()\n\t{\n\t\tvar plainText = \"Hello cruel world\".getBytes(StandardCharsets.UTF_8);\n\n\t\tvar cipherText = AES.encrypt(aesKey, iv, plainText);\n\t\tvar decryptedText = AES.decrypt(aesKey, iv, cipherText);\n\n\t\tassertArrayEquals(plainText, decryptedText);\n\t}\n\n\t@Test\n\tvoid Encrypt_AES_BadKey()\n\t{\n\t\tvar plainText = \"Hello cruel world\".getBytes(StandardCharsets.UTF_8);\n\n\t\tassertThrows(IllegalArgumentException.class, () -> AES.encrypt(new byte[8], iv, plainText));\n\t}\n\n\t@Test\n\tvoid Encrypt_AES_BadIv()\n\t{\n\t\tvar plainText = \"Hello cruel world\".getBytes(StandardCharsets.UTF_8);\n\n\t\tassertThrows(IllegalArgumentException.class, () -> AES.encrypt(aesKey, new byte[4], plainText));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/chatcipher/ChatChallengeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.chatcipher;\n\nimport io.xeres.app.crypto.hash.chat.ChatChallenge;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChatChallengeTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ChatChallenge.class);\n\t}\n\n\t@Test\n\tvoid Code_Various_Success()\n\t{\n\t\tvar gxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tvar code = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"140257447151802099\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"1540395435043678632\"), code);\n\n\t\tgxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tcode = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"3128845210392038968\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"9133905927926710723\"), code);\n\n\t\tgxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tcode = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"15552989625937603562\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"2213486716447545487\"), code);\n\n\t\tgxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tcode = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"140257447151802099\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"1540395435043678632\"), code);\n\n\t\tgxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tcode = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"3128845210392038968\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"9133905927926710723\"), code);\n\n\t\tgxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tcode = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"15552989625937603562\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"2213486716447545487\"), code);\n\n\t\tgxsId = new GxsId(Id.toBytes(\"01dc22f128d9495541f780a254b89630\"));\n\t\tcode = ChatChallenge.code(gxsId, Long.parseUnsignedLong(\"10949563242187165295\"), Long.parseUnsignedLong(\"140257447151802099\"));\n\t\tassertEquals(Long.parseUnsignedLong(\"1540395435043678632\"), code);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/dh/DiffieHellmanTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.dh;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport javax.crypto.interfaces.DHPublicKey;\nimport java.math.BigInteger;\nimport java.security.KeyPair;\n\nimport static io.xeres.app.crypto.dh.DiffieHellman.G;\nimport static io.xeres.app.crypto.dh.DiffieHellman.P;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass DiffieHellmanTest\n{\n\tprivate static KeyPair keyPair;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tkeyPair = io.xeres.app.crypto.dh.DiffieHellman.generateKeys();\n\t}\n\n\t@Test\n\tvoid utilityClassCheck() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(DiffieHellman.class);\n\t}\n\n\t@Test\n\tvoid DiffieHellman_Validate()\n\t{\n\t\tassertTrue(isSafePrime(P));\n\t\tassertTrue(isGeneratorValid(G));\n\t}\n\n\t@Test\n\tvoid DiffieHellman_Generation_Success()\n\t{\n\t\tassertNotNull(keyPair);\n\t\tassertEquals(\"DH\", keyPair.getPrivate().getAlgorithm());\n\t\tassertEquals(\"DH\", keyPair.getPublic().getAlgorithm());\n\t}\n\n\t@Test\n\tvoid DiffieHellman_GetPublicKey()\n\t{\n\t\tvar publicKeyNum = ((DHPublicKey) keyPair.getPublic()).getY();\n\n\t\tassertEquals(((DHPublicKey) keyPair.getPublic()).getY(), ((DHPublicKey) DiffieHellman.getPublicKey(publicKeyNum)).getY());\n\t}\n\n\t@Test\n\tvoid DiffieHellman_GenerateCommonSecret()\n\t{\n\t\tvar receivedKeyPair = DiffieHellman.generateKeys();\n\n\t\tvar common = DiffieHellman.generateCommonSecretKey(keyPair.getPrivate(), receivedKeyPair.getPublic());\n\n\t\tassertNotNull(common);\n\t}\n\n\t@Test\n\tvoid DiffieHellman_FullExchange()\n\t{\n\t\tvar heike = DiffieHellman.generateKeys();\n\t\tvar juergen = DiffieHellman.generateKeys();\n\n\t\tvar heikeSecret = DiffieHellman.generateCommonSecretKey(heike.getPrivate(), juergen.getPublic());\n\t\tvar juergenSecret = DiffieHellman.generateCommonSecretKey(juergen.getPrivate(), heike.getPublic());\n\n\t\tassertArrayEquals(heikeSecret, juergenSecret);\n\t}\n\n\tprivate static boolean isSafePrime(BigInteger p)\n\t{\n\t\t// Check if p is a safe prime (p = 2q + 1, where q is also prime)\n\t\tBigInteger q = p.subtract(BigInteger.ONE).divide(BigInteger.TWO);\n\t\treturn p.isProbablePrime(10) && q.isProbablePrime(10);\n\t}\n\n\tprivate static boolean isGeneratorValid(BigInteger g)\n\t{\n\t\t// Usually 2 or 5.\n\t\treturn g.equals(new BigInteger(\"2\")) || g.equals(new BigInteger(\"5\"));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/ec/Ed25519Test.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.ec;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.security.KeyPair;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nclass Ed25519Test\n{\n\tprivate static KeyPair keyPair;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tkeyPair = Ed25519.generateKeys(255);\n\t}\n\n\t@Test\n\tvoid utilityClassCheck() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(Ed25519.class);\n\t}\n\n\t@Test\n\tvoid Generation_Success()\n\t{\n\t\tassertNotNull(keyPair);\n\t\tassertEquals(\"EdDSA\", keyPair.getPrivate().getAlgorithm());\n\t\tassertEquals(\"EdDSA\", keyPair.getPublic().getAlgorithm());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/hmac/sha1/Sha1HMacTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hmac.sha1;\n\nimport org.junit.jupiter.api.Test;\n\nimport javax.crypto.spec.SecretKeySpec;\nimport java.util.HexFormat;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nclass Sha1HMacTest\n{\n\t@Test\n\tvoid RFC2202_Test_Case1()\n\t{\n\t\tvar hexFormat = HexFormat.of();\n\t\tvar keySpec = new SecretKeySpec(hexFormat.parseHex(\"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b\"), \"AES\");\n\t\tvar hmac = new Sha1HMac(keySpec);\n\n\t\tassertNotNull(hmac);\n\n\t\thmac.update(\"Hi There\".getBytes());\n\n\t\tassertArrayEquals(hexFormat.parseHex(\"b617318655057264e28bc0b6fb378c8ef146be00\"), hmac.getBytes());\n\t}\n\n\t@Test\n\tvoid RFC2202_Test_Case2()\n\t{\n\t\tvar hexFormat = HexFormat.of();\n\t\tvar keySpec = new SecretKeySpec(hexFormat.parseHex(\"4a656665\"), \"AES\");\n\t\tvar hmac = new Sha1HMac(keySpec);\n\n\t\tassertNotNull(hmac);\n\n\t\thmac.update(\"what do ya want for nothing?\".getBytes());\n\n\t\tassertArrayEquals(hexFormat.parseHex(\"effcdf6ae5eb2fa2d27416d5f184df9c259a7c79\"), hmac.getBytes());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/hmac/sha256/Sha256HMacTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.hmac.sha256;\n\nimport org.junit.jupiter.api.Test;\n\nimport javax.crypto.spec.SecretKeySpec;\nimport java.util.HexFormat;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nclass Sha256HMacTest\n{\n\t@Test\n\tvoid RFC2202_Test_Case1()\n\t{\n\t\tvar hexFormat = HexFormat.of();\n\t\tvar keySpec = new SecretKeySpec(hexFormat.parseHex(\"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b\"), \"AES\");\n\t\tvar hmac = new Sha256HMac(keySpec);\n\n\t\tassertNotNull(hmac);\n\n\t\thmac.update(\"Hi There\".getBytes());\n\n\t\tassertArrayEquals(hexFormat.parseHex(\"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7\"), hmac.getBytes());\n\t}\n\n\t@Test\n\tvoid RFC2202_Test_Case2()\n\t{\n\t\tvar hexFormat = HexFormat.of();\n\t\tvar keySpec = new SecretKeySpec(hexFormat.parseHex(\"4a656665\"), \"AES\");\n\t\tvar hmac = new Sha256HMac(keySpec);\n\n\t\tassertNotNull(hmac);\n\n\t\thmac.update(\"what do ya want for nothing?\".getBytes());\n\n\t\tassertArrayEquals(hexFormat.parseHex(\"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843\"), hmac.getBytes());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/pgp/PGPTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.pgp;\n\nimport io.xeres.testutils.TestUtils;\nimport org.bouncycastle.bcpg.ArmoredInputStream;\nimport org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.security.InvalidKeyException;\nimport java.security.Security;\nimport java.security.SignatureException;\n\nimport static io.xeres.app.crypto.pgp.PGP.*;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PGPTest\n{\n\tprivate static final int KEY_SIZE = 512;\n\tprivate static PGPSecretKey pgpSecretKey;\n\n\t@BeforeAll\n\tstatic void setup() throws PGPException\n\t{\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\n\t\tpgpSecretKey = generateSecretKey(\"test\", null, KEY_SIZE);\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(PGP.class);\n\t}\n\n\t/**\n\t * Generates a PGP secret key.\n\t */\n\t@Test\n\tvoid GenerateSecretKey_Success() throws PGPException\n\t{\n\t\tassertNotNull(pgpSecretKey);\n\t\tassertTrue(pgpSecretKey.isMasterKey());\n\t\tassertTrue(pgpSecretKey.isSigningKey());\n\t\tassertFalse(pgpSecretKey.isPrivateKeyEmpty());\n\t\tassertEquals(SymmetricKeyAlgorithmTags.AES_128, pgpSecretKey.getKeyEncryptionAlgorithm());\n\t\tassertNotNull(pgpSecretKey.getPublicKey());\n\t\tassertNotNull(pgpSecretKey.extractPrivateKey(new JcePBESecretKeyDecryptorBuilder().build(\"\".toCharArray())));\n\t}\n\n\t/**\n\t * Signs using a PGP secret key then verifies.\n\t */\n\t@Test\n\tvoid Sign_Success() throws PGPException, IOException, SignatureException\n\t{\n\t\tvar in = \"The lazy dog jumps over the drunk fox\".getBytes();\n\n\t\tvar out = new ByteArrayOutputStream();\n\n\t\tsign(pgpSecretKey, new ByteArrayInputStream(in), out, Armor.NONE);\n\n\t\tverify(pgpSecretKey.getPublicKey(), out.toByteArray(), new ByteArrayInputStream(in));\n\t}\n\n\t@Test\n\tvoid Sign_Armored_Success() throws PGPException, IOException, SignatureException\n\t{\n\t\tvar in = \"The lazy dog jumps over the drunk fox\".getBytes();\n\n\t\tvar out = new ByteArrayOutputStream();\n\n\t\tsign(pgpSecretKey, new ByteArrayInputStream(in), out, Armor.BASE64);\n\n\t\tverify(pgpSecretKey.getPublicKey(), new ArmoredInputStream(new ByteArrayInputStream(out.toByteArray())).readAllBytes(), new ByteArrayInputStream(in));\n\t}\n\n\t/**\n\t * Signs using a PGP secret key then verifies with another.\n\t */\n\t@Test\n\tvoid Sign_WrongKey_Failure() throws PGPException, IOException\n\t{\n\t\tvar in = \"The lazy dog jumps over the drunk fox\".getBytes();\n\n\t\tvar pgpSecretKey2 = generateSecretKey(\"test2\", null, KEY_SIZE);\n\n\t\tvar out = new ByteArrayOutputStream();\n\n\t\tsign(pgpSecretKey, new ByteArrayInputStream(in), out, Armor.NONE);\n\n\t\tassertThatThrownBy(() -> verify(pgpSecretKey2.getPublicKey(), out.toByteArray(), new ByteArrayInputStream(in)))\n\t\t\t\t.isInstanceOf(SignatureException.class);\n\t}\n\n\t@Test\n\tvoid GetSecretKey_Success() throws IOException\n\t{\n\t\tassertEquals(pgpSecretKey.getKeyID(), getPGPSecretKey(pgpSecretKey.getEncoded()).getKeyID());\n\t}\n\n\t@Test\n\tvoid GetSecretKey_Corrupted_Failure()\n\t{\n\t\tassertThatThrownBy(() -> getPGPSecretKey(new byte[]{1, 2, 3}))\n\t\t\t\t.isInstanceOf(IllegalArgumentException.class)\n\t\t\t\t.hasMessageContaining(\"corrupted\");\n\t}\n\n\t@Test\n\tvoid GetPublicKey_Success() throws IOException, InvalidKeyException\n\t{\n\t\tassertEquals(pgpSecretKey.getPublicKey().getKeyID(), getPGPPublicKey(pgpSecretKey.getPublicKey().getEncoded()).getKeyID());\n\t}\n\n\t@Test\n\tvoid GetPublicKey_Corrupted_Failure()\n\t{\n\t\tassertThatThrownBy(() -> getPGPPublicKey(new byte[]{1, 2, 3}))\n\t\t\t\t.isInstanceOf(InvalidKeyException.class)\n\t\t\t\t.hasMessageContaining(\"corrupted\");\n\t}\n\n\t@Test\n\tvoid GetPublicKeyArmored_Success() throws IOException\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\t\tgetPublicKeyArmored(pgpSecretKey.getPublicKey(), out);\n\n\t\tvar output = out.toString();\n\n\t\tassertTrue(output.contains(\"BEGIN PGP\"));\n\t\tassertTrue(output.contains(\"END PGP\"));\n\t}\n\n\t@Test\n\tvoid GetUpdateForSigning_Success() throws PGPException, IOException\n\t{\n\t\tvar updateSigningKey = getUpdateSigningKey();\n\n\t\tassertNotNull(updateSigningKey);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsa/RSATest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsa;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.io.Serial;\nimport java.security.KeyPair;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.PrivateKey;\nimport java.security.spec.InvalidKeySpecException;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass RSATest\n{\n\tprivate static final int KEY_SIZE = 512;\n\n\tprivate static KeyPair keyPair;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tkeyPair = RSA.generateKeys(KEY_SIZE);\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(RSA.class);\n\t}\n\n\t/**\n\t * Generates an RSA secret key.\n\t */\n\t@Test\n\tvoid GenerateKeys_Success()\n\t{\n\t\tassertNotNull(keyPair);\n\t\tassertEquals(\"RSA\", keyPair.getPrivate().getAlgorithm());\n\t\tassertEquals(\"RSA\", keyPair.getPublic().getAlgorithm());\n\t}\n\n\t@Test\n\tvoid GetPrivateKey_Success() throws InvalidKeySpecException, NoSuchAlgorithmException\n\t{\n\t\tassertEquals(keyPair.getPrivate(), RSA.getPrivateKey(keyPair.getPrivate().getEncoded()));\n\t}\n\n\t@Test\n\tvoid GetPublicKey_Success() throws InvalidKeySpecException, NoSuchAlgorithmException\n\t{\n\t\tassertEquals(keyPair.getPublic(), RSA.getPublicKey(keyPair.getPublic().getEncoded()));\n\t}\n\n\t@Test\n\tvoid Sign_Success()\n\t{\n\t\tbyte[] data = {1, 2, 3};\n\n\t\tvar signature = RSA.sign(keyPair.getPrivate(), data);\n\n\t\tassertNotNull(signature);\n\n\t\tvar result = RSA.verify(keyPair.getPublic(), signature, data);\n\n\t\tassertTrue(result);\n\t}\n\n\t@Test\n\tvoid Sign_TemperedData_Failure()\n\t{\n\t\tbyte[] data = {1, 2, 3};\n\n\t\tvar signature = RSA.sign(keyPair.getPrivate(), data);\n\n\t\tassertNotNull(signature);\n\n\t\tdata[0] = 0;\n\n\t\tvar result = RSA.verify(keyPair.getPublic(), signature, data);\n\n\t\tassertFalse(result);\n\t}\n\n\t@Test\n\tvoid Sign_InvalidKey_ThrowsException()\n\t{\n\t\tbyte[] data = {1, 2, 3};\n\t\tvar privateKey = new PrivateKey()\n\t\t{\n\t\t\t@Serial\n\t\t\tprivate static final long serialVersionUID = -5166467762224595264L;\n\n\t\t\t@Override\n\t\t\tpublic String getAlgorithm()\n\t\t\t{\n\t\t\t\treturn \"RSA\";\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic String getFormat()\n\t\t\t{\n\t\t\t\treturn \"PKCS#8\";\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic byte[] getEncoded()\n\t\t\t{\n\t\t\t\treturn new byte[0]; // Invalid key\n\t\t\t}\n\t\t};\n\n\t\tassertThrows(IllegalArgumentException.class, () -> RSA.sign(privateKey, data));\n\t}\n\n\t@Test\n\tvoid Convert_Private_Pkcs8_To_Pkcs1_And_Back_Success() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException\n\t{\n\t\tvar pkcs1 = RSA.getPrivateKeyAsPkcs1(keyPair.getPrivate());\n\t\tvar privateKey = RSA.getPrivateKeyFromPkcs1(pkcs1);\n\n\t\tassertArrayEquals(keyPair.getPrivate().getEncoded(), privateKey.getEncoded());\n\t}\n\n\t@Test\n\tvoid Convert_Public_X509_To_Pkcs1_And_Back_Success() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException\n\t{\n\t\tvar pkcs1 = RSA.getPublicKeyAsPkcs1(keyPair.getPublic());\n\t\tvar publicKey = RSA.getPublicKeyFromPkcs1(pkcs1);\n\n\t\tassertArrayEquals(keyPair.getPublic().getEncoded(), publicKey.getEncoded());\n\t}\n\n\t@Test\n\tvoid GetGxsId_Insecure()\n\t{\n\t\t// noinspection deprecation\n\t\tvar gxsIdInsecure = RSA.getGxsIdInsecure(keyPair.getPublic());\n\n\t\tassertNotNull(gxsIdInsecure);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rscrypto/RsCryptoTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rscrypto;\n\nimport io.xeres.app.crypto.aead.AEAD;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport javax.crypto.SecretKey;\nimport java.nio.charset.StandardCharsets;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\n\nclass RsCryptoTest\n{\n\tprivate static SecretKey key;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tkey = AEAD.generateKey();\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(RsCrypto.class);\n\t}\n\n\t@Test\n\tvoid ChaCha20Sha256_Encrypt_Decrypt_Success()\n\t{\n\t\tvar plainText = \"hello, world\".getBytes(StandardCharsets.UTF_8);\n\n\t\tvar cipherText = RsCrypto.encryptAuthenticateData(key, plainText, RsCrypto.EncryptionFormat.CHACHA20_SHA256);\n\t\tvar decryptedText = RsCrypto.decryptAuthenticateData(key, cipherText);\n\n\t\tassertArrayEquals(plainText, decryptedText);\n\t}\n\n\t@Test\n\tvoid ChaCha20Poly1305_Encrypt_Decrypt_Success()\n\t{\n\t\tvar plainText = \"bye, cruel world\".getBytes(StandardCharsets.UTF_8);\n\n\t\tvar cipherText = RsCrypto.encryptAuthenticateData(key, plainText, RsCrypto.EncryptionFormat.CHACHA20_POLY1305);\n\t\tvar decryptedText = RsCrypto.decryptAuthenticateData(key, cipherText);\n\n\t\tassertArrayEquals(plainText, decryptedText);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsid/RSCertificateTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport static io.xeres.common.rsid.Type.CERTIFICATE;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass RSCertificateTest\n{\n\t@Test\n\tvoid Build_Success()\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"Nemesis\", 0x9F00B21277698D8DL, Id.toBytes(\"60049f670534eab17dda2e6d9f00b21277698d8d\"), Id.toBytes(\"984d0461fd80400102008e20511e623f662693d054e1aeb26a007e17f745d4616a6a647d22313b67111ce5f45db22fb670bb5e05f4846ad6d686224acc22966f28e1a50d99d4afb295fb0011010001b4084e656d6573697320885c041001020006050261fd8040000a09109f00b21277698d8d97e401ff688d2b9b73551587858994309485909a36b5401518716698131e1811d8f8204348392c89e99fcb21651d7490e9877b80ced7e11aabbb7c0538853954d77d047b\"));\n\t\tvar location = LocationFakes.createLocation(\"Home\", profile, LocationIdentifier.fromString(\"738ea192064e3f20e766438cc9305bd5\"));\n\n\t\tvar rsId = new RSIdBuilder(CERTIFICATE)\n\t\t\t\t.setName(profile.getName().getBytes())\n\t\t\t\t.setProfile(profile)\n\t\t\t\t.setLocationIdentifier(location.getLocationIdentifier())\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"192.168.1.50:1234\")))\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"85.1.2.3:1234\")))\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromHostname(\"foo.bar.com\")))\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"85.1.2.4:1234\")))\n\t\t\t\t.build();\n\n\t\tvar armored = rsId.getArmored();\n\n\t\tassertEquals(\"\"\"\n\t\t\t\tCQEGAbeYTQRh/YBAAQIAjiBRHmI/ZiaT0FThrrJqAH4X90XUYWpqZH0iMTtnERzl\n\t\t\t\t9F2yL7Zwu14F9IRq1taGIkrMIpZvKOGlDZnUr7KV+wARAQABtAhOZW1lc2lzIIhc\n\t\t\t\tBBABAgAGBQJh/YBAAAoJEJ8AshJ3aY2Nl+QB/2iNK5tzVRWHhYmUMJSFkJo2tUAV\n\t\t\t\tGHFmmBMeGBHY+CBDSDksiemfyyFlHXSQ6Yd7gM7X4Rqru3wFOIU5VNd9BHsCBlUB\n\t\t\t\tAgME0gMGwKgBMgTSBA1mb28uYmFyLmNvbQTSBgdOZW1lc2lzBRBzjqGSBk4/IOdm\n\t\t\t\tQ4zJMFvVCgZVAQIEBNIHA90yoQ==\"\"\", armored);\n\t}\n\n\t@Test\n\tvoid Parse_Success()\n\t{\n\t\tvar string = \"\"\"\n\t\t\t\tCQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2\n\t\t\t\tgRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU\n\t\t\t\t9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBa\n\t\t\t\tM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P\n\t\t\t\t6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCg\n\t\t\t\tNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChH\n\t\t\t\tZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE\n\t\t\t\t3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD\n\t\t\t\t2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz\n\t\t\t\t2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIY\n\t\t\t\tHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkE\n\t\t\t\tzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfi\n\t\t\t\tF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhv\n\t\t\t\tbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEH\n\t\t\t\tAxnSjw==\"\"\";\n\n\t\tvar rsId = RSId.parse(string, CERTIFICATE);\n\n\t\tassertTrue(rsId.isPresent());\n\t\tassertNotNull(rsId.get().getPgpPublicKey());\n\t\tassertFalse(rsId.get().getInternalIp().isPresent()); // RS put 169.254.67.38 in my certificate...\n\t\tassertTrue(rsId.get().getExternalIp().isPresent());\n\t\tassertNotNull(rsId.get().getLocationIdentifier());\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t// Empty\n\t\t\t\"\",\n\t\t\t// Wrong certificate version\n\t\t\t\"CQEFAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAxnSjw==\",\n\t\t\t// No version\n\t\t\t\"AcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAxnSjw==\",\n\t\t\t// Wrong checksum\n\t\t\t\"CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAxnSjg==\",\n\t\t\t// Wrong checksum length\n\t\t\t\"CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHAhnSjw==\",\n\t\t\t// Missing checksum\n\t\t\t\"CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wE=\",\n\t\t\t// Packet shorter than advertised length\n\t\t\t\"CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIFEHql0D0fEzStg7Xe+nDb7wEHBBnSjw==\",\n\t\t\t// Missing location id\n\t\t\t\"CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQYLTXkgY29tcHV0ZXIHA9kC3w==\",\n\t\t\t// Missing name\n\t\t\t\"CQEGAcGWxsBNBFpq3M0BCADEQWXjoNmUNDo/RSfYwlSavOQoTllnlLv7bmRHXRP2gRxBlCjp185VyI+mW9uWbNnv8TpMsScjKvS+x0uE3QoqjW9seSxq1hIu5ba3cDbU9CzhKfAyycreIWtjZn18IqfvQ3qg3yJ+JLYptA10AGO0ErCmMyhtXAeDthCD3JBaM+jCXi0KGg5k2SkQq9OS+/ktD3/izLX5Zeo5z41s9pSRe5nGQd0vpcwSHTLCUK9P6okDXLNG5jjcLfHD6ap74oTb/My/XOCqprLHIcm00/Byabd9HsZ2Z63KK9ZJ8NCgNwAX1dBwTx1dESdre7+GxUaE3aMYCBon2ZwsTvKv4mjPABEBAAHNInphcGVrIChHZW5lcmF0ZWQgYnkgUmV0cm9TaGFyZSkgPD7CwF8EEwECABMFAlpq3M0JEBM+UlCE3l1NAhkBAAD4mwf/RH/aoFKos9gNCOts9d8TcLhwzvIA++Ah2gmfBcdD9yS7bfiD2cR+qazwhl8GFuCldrUIs+oX0MpN7u2eBX26IH9qwszRQLsEgxETvTxc+0lSE/uz2j+YDQ3fU++ARu5/FKH6HwYspxE+NDnxnaqjkZNAtJmUUBnp9wW2LfkEvHLVnmIYHIQQSSalA2yOzVd0Onf6WJJshctiBbglEZMViN3sypMeoYDct3qhGNCk0E3yojkEzS/CSzueXKS2jucYaybaouACvQ/hlyJeGuv0Ba//lupYn6xRonNzuS8oMcJmUBfiF9pVssvzvyfTIoyD8WGEI3COvthDhKDzF+5rOgIGVEvWwHwOAwap/kMmfA4EEmhvbWUuZHluLnphcGVrLmNvbQUQeqXQPR8TNK2Dtd76cNvvAQcDtYSn\",\n\t\t\t// Missing PGP key\n\t\t\t\"CQEGAgZUS9bAfA4DBqn+QyZ8DgQSaG9tZS5keW4uemFwZWsuY29tBgtNeSBjb21wdXRlcgUQeqXQPR8TNK2Dtd76cNvvAQcDHrnJ\"\n\t})\n\tvoid Parse_Error(String string)\n\t{\n\t\tvar rsId = RSId.parse(string, CERTIFICATE);\n\n\t\tassertFalse(rsId.isPresent());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsid/RSIdArmorTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass RSIdArmorTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(RSIdArmor.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsid/RSIdCrcTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.crypto.rsid.RSIdCrc.calculate24bitsCrc;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass RSIdCrcTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(RSIdCrc.class);\n\t}\n\n\t@Test\n\tvoid Calculate24BitsCrc_Success()\n\t{\n\t\tvar input = \"The quick brown fox jumps over the lazy dog\".getBytes();\n\t\tassertEquals(10641804, calculate24bitsCrc(input, input.length));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsid/RSIdFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.testutils.StringFakes;\n\nimport static io.xeres.common.rsid.Type.CERTIFICATE;\nimport static io.xeres.common.rsid.Type.SHORT_INVITE;\n\npublic final class RSIdFakes\n{\n\tprivate RSIdFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static RSId createShortInvite()\n\t{\n\t\tvar profile = ProfileFakes.createProfile();\n\n\t\tvar builder = new RSIdBuilder(SHORT_INVITE);\n\t\treturn builder.setName(StringFakes.createNickname().getBytes())\n\t\t\t\t.setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier())\n\t\t\t\t.setPgpFingerprint(profile.getProfileFingerprint().getBytes())\n\t\t\t\t.build();\n\t}\n\n\tpublic static RSId createRsCertificate()\n\t{\n\t\tvar builder = new RSIdBuilder(CERTIFICATE);\n\t\treturn builder.setName(StringFakes.createNickname().getBytes())\n\t\t\t\t.setProfile(ProfileFakes.createProfile())\n\t\t\t\t.setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier())\n\t\t\t\t.build();\n\t}\n\n\tpublic static RSId createRsCertificate(Profile profile)\n\t{\n\t\tvar builder = new RSIdBuilder(CERTIFICATE);\n\t\treturn builder.setName(profile.getName().getBytes())\n\t\t\t\t.setProfile(profile)\n\t\t\t\t.setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier())\n\t\t\t\t.build();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsid/RSSerialVersionTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.math.BigInteger;\nimport java.util.concurrent.ThreadLocalRandom;\n\nimport static io.xeres.app.crypto.rsid.RSSerialVersion.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass RSSerialVersionTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, V06_0000.ordinal());\n\t\tassertEquals(1, V06_0001.ordinal());\n\t\tassertEquals(2, V07_0001.ordinal());\n\n\t\tassertEquals(3, values().length);\n\t}\n\n\t@Test\n\tvoid GetFromSerialNumber_Success()\n\t{\n\t\tvar rsOld = new BigInteger(Integer.toString(ThreadLocalRandom.current().nextInt(100000, 2000000000)), 16);\n\t\tvar rs6_4 = new BigInteger(\"60000\", 16);\n\t\tvar rs6_5 = new BigInteger(\"60001\", 16);\n\t\tvar rs7 = new BigInteger(\"70001\", 16);\n\n\t\tassertEquals(V06_0000, RSSerialVersion.getFromSerialNumber(rs6_4));\n\t\tassertEquals(RSSerialVersion.V06_0001, RSSerialVersion.getFromSerialNumber(rs6_5));\n\t\tassertEquals(RSSerialVersion.V07_0001, RSSerialVersion.getFromSerialNumber(rs7));\n\t\tassertEquals(V06_0000, RSSerialVersion.getFromSerialNumber(rsOld));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/rsid/RSShortInviteTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.rsid;\n\nimport io.xeres.app.database.model.connection.Connection;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport static io.xeres.app.crypto.rsid.ShortInvite.*;\nimport static io.xeres.common.rsid.Type.SHORT_INVITE;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass RSShortInviteTest\n{\n\t@Test\n\tvoid Values()\n\t{\n\t\tassertEquals(0x0, SSL_ID);\n\t\tassertEquals(0x1, NAME);\n\t\tassertEquals(0x2, LOCATOR);\n\t\tassertEquals(0x3, PGP_FINGERPRINT);\n\t\tassertEquals(0X4, CHECKSUM);\n\t\tassertEquals(0X90, HIDDEN_LOCATOR);\n\t\tassertEquals(0X91, DNS_LOCATOR);\n\t\tassertEquals(0X92, EXT4_LOCATOR);\n\t\tassertEquals(0X93, LOC4_LOCATOR);\n\t}\n\n\t@Test\n\tvoid SwapBytes_Success()\n\t{\n\t\tvar input = new byte[]{1, 2, 3, 4, 5, 6};\n\t\tvar output = new byte[]{4, 3, 2, 1, 5, 6};\n\n\t\tassertArrayEquals(output, swapBytes(input));\n\t}\n\n\t@Test\n\tvoid SwapBytes_WrongInput_NoSwap()\n\t{\n\t\tvar input = new byte[]{1, 2, 3, 4, 5, 6, 7};\n\t\tvar output = new byte[]{1, 2, 3, 4, 5, 6, 7};\n\n\t\tassertArrayEquals(output, swapBytes(input));\n\t}\n\n\t@Test\n\tvoid Build_Success()\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"Nemesis\", 0x792b20ca657e2706L, Id.toBytes(\"06d4b446d209e752fa711a39792b20ca657e2706\"), new byte[]{1});\n\t\tvar location = LocationFakes.createLocation(\"Home\", profile, LocationIdentifier.fromString(\"738ea192064e3f20e766438cc9305bd5\"));\n\n\t\tvar rsId = new RSIdBuilder(SHORT_INVITE)\n\t\t\t\t.setName(profile.getName().getBytes())\n\t\t\t\t.setPgpFingerprint(profile.getProfileFingerprint().getBytes())\n\t\t\t\t.setLocationIdentifier(location.getLocationIdentifier())\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"192.168.1.50:1234\")))\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"85.1.2.3:1234\")))\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"foo.bar.com:1234\")))\n\t\t\t\t.addLocator(Connection.from(PeerAddress.fromAddress(\"85.1.2.4:1234\")))\n\t\t\t\t.build();\n\n\t\tvar armored = rsId.getArmored();\n\n\t\tassertEquals(\"ABBzjqGSBk4/IOdmQ4zJMFvVAQdOZW1lc2lzAxQG1LRG0gnnUvpxGjl5KyDKZX4nBpENBNJmb28uYmFyLmNvbZIGAwIBVQTSkwYyAajABNICFGlwdjQ6Ly84NS4xLjIuNDoxMjM0BAOiD+U=\", armored);\n\t}\n\n\t@Test\n\tvoid Parse_Success()\n\t{\n\t\tvar string = \"\\nABBzjqGSBk4/IOdmQ4zJMFvVAQdOZW1lc2lzAxQG1LRG0gnnUvpxGjl5KyDKZX4nBpENBNJmb28uYmFyLmNvbZIGAwIBVQTSkwYyAajABNICFGlwdjQ6Ly84NS4xLjIuNDoxMjM0BAOiD+U=\\n\";\n\n\t\tvar rsId = RSId.parse(string, SHORT_INVITE);\n\n\t\tassertTrue(rsId.isPresent());\n\n\t\tassertEquals(\"Nemesis\", rsId.get().getName());\n\n\t\tassertEquals(0x792b20ca657e2706L, rsId.get().getPgpIdentifier());\n\t\tassertArrayEquals(Id.toBytes(\"06d4b446d209e752fa711a39792b20ca657e2706\"), rsId.get().getPgpFingerprint().getBytes());\n\n\t\tassertArrayEquals(Id.toBytes(\"738ea192064e3f20e766438cc9305bd5\"), rsId.get().getLocationIdentifier().getBytes());\n\n\t\tassertTrue(rsId.get().getHiddenNodeAddress().isEmpty());\n\n\t\tassertTrue(rsId.get().getInternalIp().isPresent());\n\t\tassertTrue(rsId.get().getInternalIp().get().getAddress().isPresent());\n\t\tassertEquals(\"192.168.1.50:1234\", rsId.get().getInternalIp().get().getAddress().get());\n\n\t\tassertTrue(rsId.get().getExternalIp().isPresent());\n\t\tassertTrue(rsId.get().getExternalIp().get().getAddress().isPresent());\n\t\tassertEquals(\"85.1.2.3:1234\", rsId.get().getExternalIp().get().getAddress().get());\n\n\t\tassertTrue(rsId.get().getDnsName().isPresent());\n\t\tassertTrue(rsId.get().getDnsName().get().getAddress().isPresent());\n\t\tassertEquals(\"foo.bar.com:1234\", rsId.get().getDnsName().get().getAddress().get());\n\n\t\tassertFalse(rsId.get().getLocators().isEmpty());\n\t\tassertTrue(rsId.get().getLocators().stream().findFirst().isPresent());\n\t\tassertEquals(\"85.1.2.4:1234\", rsId.get().getLocators().stream().findFirst().get().getAddress().orElseThrow());\n\t}\n\n\t@Test\n\tvoid Parse_Empty()\n\t{\n\t\tvar string = \"\";\n\n\t\tvar rsId = RSId.parse(string, SHORT_INVITE);\n\n\t\tassertFalse(rsId.isPresent());\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t// Empty\n\t\t\t\"\",\n\t\t\t// Wrong checksum\n\t\t\t\"ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEA6cUSw==\",\n\t\t\t// Wrong checksum length\n\t\t\t\"ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEAqcUSg==\",\n\t\t\t// Missing checksum\n\t\t\t\"ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbM=\",\n\t\t\t// Packet shorter than advertised length\n\t\t\t\"ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEBKcUSg==\",\n\t\t\t// Missing location id\n\t\t\t\"AQpaYXBla1hlcmVzAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEA4YtNA==\",\n\t\t\t// Missing name\n\t\t\t\"ABCE1fl2NmWv3Ri9EjwzgIHAAxRBmhvGfPlWxi+DfVZv7SmEFhoE0pIG/tnDVUGzkwZOAajAQbMEAzEjTQ==\",\n\t\t\t// Missing PGP fingerprint\n\t\t\t\"ABCE1fl2NmWv3Ri9EjwzgIHAAQpaYXBla1hlcmVzkgb+2cNVQbOTBk4BqMBBswQDXfgj\"\n\t})\n\tvoid Parse_Error(String string)\n\t{\n\t\tvar rsId = RSId.parse(string, SHORT_INVITE);\n\n\t\tassertFalse(rsId.isPresent());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/crypto/x509/X509Test.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.crypto.x509;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.crypto.rsid.RSSerialVersion;\nimport io.xeres.testutils.TestUtils;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.math.BigInteger;\nimport java.security.KeyPair;\nimport java.security.Security;\nimport java.security.SignatureException;\nimport java.security.cert.CertificateException;\nimport java.util.Date;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nclass X509Test\n{\n\tprivate static final int KEY_SIZE = 512;\n\tprivate static PGPSecretKey pgpSecretKey;\n\tprivate static KeyPair keyPair;\n\n\t@BeforeAll\n\tstatic void setup() throws PGPException\n\t{\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\n\t\tpgpSecretKey = PGP.generateSecretKey(\"test\", null, KEY_SIZE);\n\t\tkeyPair = RSA.generateKeys(KEY_SIZE);\n\t}\n\n\t@Test\n\tvoid Instance_ThrowException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(X509.class);\n\t}\n\n\t/**\n\t * Generates an X509 certificate.\n\t */\n\t@Test\n\tvoid GenerateCertificate_Success() throws PGPException, IOException, CertificateException, SignatureException\n\t{\n\t\tgenerateCertificate(RSSerialVersion.V07_0001.serialNumber());\n\t}\n\n\t@Test\n\tvoid GenerateCertificate_OldRS_0_6_5_Success() throws PGPException, IOException, CertificateException, SignatureException\n\t{\n\t\tgenerateCertificate(RSSerialVersion.V06_0001.serialNumber());\n\t}\n\n\t@Test\n\tvoid GenerateCertificate_OldestRS_Success() throws PGPException, IOException, CertificateException, SignatureException\n\t{\n\t\tgenerateCertificate(new BigInteger(\"123456\", 16));\n\t}\n\n\tprivate void generateCertificate(BigInteger serialNumber) throws IOException, CertificateException, PGPException, SignatureException\n\t{\n\t\tvar issuer = \"CN=1234\";\n\t\tvar subject = \"CN=-\";\n\t\tvar from = new Date(0);\n\t\tvar to = new Date(0);\n\n\t\tvar cert = X509.generateCertificate(pgpSecretKey, keyPair.getPublic(), issuer, subject, from, to, serialNumber);\n\t\tassertNotNull(cert);\n\t\tassertEquals(issuer, cert.getIssuerX500Principal().getName());\n\t\tassertEquals(subject, cert.getSubjectX500Principal().getName());\n\t\tassertEquals(serialNumber, cert.getSerialNumber());\n\t\tassertEquals(from, cert.getNotBefore());\n\t\tassertEquals(to, cert.getNotAfter());\n\t\tPGP.verify(pgpSecretKey.getPublicKey(), cert.getSignature(), new ByteArrayInputStream(cert.getTBSCertificate()));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/chat/ChatMapperTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChatMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ChatMapper.class);\n\t}\n\n\t@Test\n\tvoid toDTO_Success()\n\t{\n\t\tvar chatRoom = ChatRoomFakes.createChatRoom();\n\t\tvar chatRoomDTO = ChatMapper.toDTO(chatRoom.getAsRoomInfo());\n\n\t\tassertEquals(chatRoom.getId(), chatRoomDTO.id());\n\t\tassertEquals(chatRoom.getName(), chatRoomDTO.name());\n\t\tassertEquals(chatRoom.getTopic(), chatRoomDTO.topic());\n\t\tassertEquals(chatRoom.isSigned(), chatRoomDTO.isSigned());\n\t\t// flags aren't compared as their logic is different\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/chat/ChatRoomFakes.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.chat;\n\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.message.chat.RoomType;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class ChatRoomFakes\n{\n\tprivate ChatRoomFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatRoom createChatRoomEntity()\n\t{\n\t\treturn createChatRoomEntity(IdFakes.createLong(), IdentityFakes.createOwn(), RandomStringUtils.insecure().nextAlphabetic(8), RandomStringUtils.insecure().nextAlphabetic(8), 0);\n\t}\n\n\tpublic static ChatRoom createChatRoomEntity(IdentityGroupItem identityGroupItem)\n\t{\n\t\treturn createChatRoomEntity(IdFakes.createLong(), identityGroupItem, RandomStringUtils.insecure().nextAlphabetic(8), RandomStringUtils.insecure().nextAlphabetic(8), 0);\n\t}\n\n\tpublic static ChatRoom createChatRoomEntity(long roomId, IdentityGroupItem identityGroupItem, String name, String topic, int flags)\n\t{\n\t\treturn new ChatRoom(roomId, identityGroupItem, name, topic, flags);\n\t}\n\n\tpublic static io.xeres.app.xrs.service.chat.ChatRoom createChatRoom()\n\t{\n\t\treturn createChatRoom(IdFakes.createLong(), RandomStringUtils.insecure().nextAlphabetic(8), RandomStringUtils.insecure().nextAlphabetic(8), RoomType.PUBLIC, 5, false);\n\t}\n\n\tpublic static io.xeres.app.xrs.service.chat.ChatRoom createChatRoom(long id, String name, String topic, RoomType roomType, int userCount, boolean isSigned)\n\t{\n\t\treturn new io.xeres.app.xrs.service.chat.ChatRoom(id, name, topic, roomType, userCount, isSigned);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/connection/ConnectionFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.connection;\n\nimport io.xeres.app.net.protocol.PeerAddress;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class ConnectionFakes\n{\n\tprivate ConnectionFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Connection createConnection()\n\t{\n\t\tvar r = ThreadLocalRandom.current();\n\t\treturn createConnection(PeerAddress.Type.IPV4, r.nextInt(11, 110) + \".\" +\n\t\t\t\t\t\tr.nextInt(1, 254) + \".\" +\n\t\t\t\t\t\tr.nextInt(1, 254) + \".\" +\n\t\t\t\t\t\tr.nextInt(1, 254) + \":\" +\n\t\t\t\t\t\tr.nextInt(1025, 65534),\n\t\t\t\tfalse);\n\t}\n\n\tpublic static Connection createConnection(PeerAddress.Type type, String address, boolean isExternal)\n\t{\n\t\tvar connection = new Connection();\n\t\tconnection.setType(type);\n\t\tconnection.setAddress(address);\n\t\tconnection.setExternal(isExternal);\n\t\treturn connection;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/connection/ConnectionMapperTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.connection;\n\nimport io.xeres.common.dto.connection.ConnectionDTO;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Instant;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ConnectionMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ConnectionMapper.class);\n\t}\n\n\t@Test\n\tvoid toDTO_Success()\n\t{\n\t\tvar connection = ConnectionFakes.createConnection();\n\t\tvar connectionDTO = ConnectionMapper.toDTO(connection);\n\n\t\tassertEquals(connection.getId(), connectionDTO.id());\n\t\tassertEquals(connection.getAddress(), connectionDTO.address());\n\t\tassertEquals(connection.getLastConnected(), connectionDTO.lastConnected());\n\t\tassertEquals(connection.isExternal(), connectionDTO.external());\n\t}\n\n\t@Test\n\tvoid fromDTO_Success()\n\t{\n\t\tvar connectionDTO = new ConnectionDTO(\n\t\t\t\t1L,\n\t\t\t\t\"85.11.11.12\",\n\t\t\t\tInstant.now(),\n\t\t\t\ttrue\n\t\t);\n\n\t\tvar connection = ConnectionMapper.fromDTO(connectionDTO);\n\n\t\tassertEquals(connectionDTO.id(), connection.getId());\n\t\tassertEquals(connectionDTO.address(), connection.getAddress());\n\t\tassertEquals(connectionDTO.external(), connection.isExternal());\n\t\tassertEquals(connectionDTO.lastConnected(), connection.getLastConnected());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/connection/ConnectionTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.connection;\n\nimport io.xeres.app.net.protocol.PeerAddress;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ConnectionTest\n{\n\t@Test\n\tvoid From_PeerAddress()\n\t{\n\t\tvar ip = \"1.1.1.1\";\n\t\tvar port = 1234;\n\t\tvar peerAddress = PeerAddress.from(ip, port);\n\t\tvar connection = Connection.from(peerAddress);\n\n\t\tassertEquals(peerAddress.getType(), connection.getType());\n\t\tassertEquals(peerAddress.isExternal(), connection.isExternal());\n\t\tassertEquals(ip, connection.getIp());\n\t\tassertEquals(port, connection.getPort());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/file/FileFakes.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.file;\n\nimport io.xeres.common.id.Sha1Sum;\n\nimport java.time.Instant;\n\npublic final class FileFakes\n{\n\tprivate FileFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static File createFile(String name)\n\t{\n\t\treturn createFile(name, null);\n\t}\n\n\tpublic static File createFile(String name, File parent)\n\t{\n\t\tvar file = new File();\n\t\tfile.setName(name);\n\n\t\tif (parent != null)\n\t\t{\n\t\t\tfile.setParent(parent);\n\t\t}\n\t\treturn file;\n\t}\n\n\tpublic static File createFile(String name, long size)\n\t{\n\t\treturn createFile(name, size, null);\n\t}\n\n\tpublic static File createFile(String name, long size, Instant modified)\n\t{\n\t\treturn createFile(name, size, modified, null);\n\t}\n\n\tpublic static File createFile(String name, long size, Instant modified, Sha1Sum hash)\n\t{\n\t\tvar file = new File();\n\t\tfile.setName(name);\n\t\tfile.setSize(size);\n\t\tfile.setModified(modified);\n\t\tfile.setHash(hash);\n\t\treturn file;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/BoardGroupItemFakes.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.xrs.service.board.item.BoardGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class BoardGroupItemFakes\n{\n\tprivate BoardGroupItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static BoardGroupItem createBoardGroupItem()\n\t{\n\t\treturn createBoardGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tpublic static BoardGroupItem createBoardGroupItem(GxsId gxsId, String name)\n\t{\n\t\tvar item = new BoardGroupItem(gxsId, name);\n\t\titem.setDescription(RandomStringUtils.insecure().nextAlphabetic(8));\n\t\treturn item;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/BoardMessageItemFakes.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.xrs.service.board.item.BoardMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class BoardMessageItemFakes\n{\n\tprivate BoardMessageItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static BoardMessageItem createBoardMessageItem()\n\t{\n\t\treturn createBoardMessageItem(IdFakes.createGxsId(), IdFakes.createMsgId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tprivate static BoardMessageItem createBoardMessageItem(GxsId gxsId, MsgId msgId, String name)\n\t{\n\t\treturn new BoardMessageItem(gxsId, msgId, name);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/ChannelGroupItemFakes.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.xrs.service.channel.item.ChannelGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class ChannelGroupItemFakes\n{\n\tprivate ChannelGroupItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChannelGroupItem createChannelGroupItem()\n\t{\n\t\treturn createChannelGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tpublic static ChannelGroupItem createChannelGroupItem(GxsId gxsId, String name)\n\t{\n\t\tvar item = new ChannelGroupItem(gxsId, name);\n\t\titem.setDescription(RandomStringUtils.insecure().nextAlphabetic(8));\n\t\treturn item;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/ChannelMessageItemFakes.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.xrs.service.channel.item.ChannelMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class ChannelMessageItemFakes\n{\n\tprivate ChannelMessageItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChannelMessageItem createChannelMessageItem()\n\t{\n\t\treturn createChannelMessageItem(IdFakes.createGxsId(), IdFakes.createMsgId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tprivate static ChannelMessageItem createChannelMessageItem(GxsId gxsId, MsgId msgId, String name)\n\t{\n\t\tvar item = new ChannelMessageItem(gxsId, msgId, name);\n\t\titem.setContent(RandomStringUtils.insecure().nextAlphabetic(20));\n\t\treturn item;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/ForumGroupItemFakes.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.xrs.service.forum.item.ForumGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class ForumGroupItemFakes\n{\n\tprivate ForumGroupItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ForumGroupItem createForumGroupItem()\n\t{\n\t\treturn createForumGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tpublic static ForumGroupItem createForumGroupItem(GxsId gxsId, String name)\n\t{\n\t\tvar item = new ForumGroupItem(gxsId, name);\n\t\titem.setDescription(RandomStringUtils.insecure().nextAlphabetic(8));\n\t\treturn item;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/ForumMessageItemFakes.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.database.model.forum.ForumMessageItemSummary;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\nimport java.time.Instant;\n\npublic final class ForumMessageItemFakes\n{\n\tprivate ForumMessageItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ForumMessageItem createForumMessageItem()\n\t{\n\t\treturn createForumMessageItem(IdFakes.createGxsId(), IdFakes.createMsgId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tprivate static ForumMessageItem createForumMessageItem(GxsId gxsId, MsgId msgId, String name)\n\t{\n\t\treturn new ForumMessageItem(gxsId, msgId, name);\n\t}\n\n\tpublic static ForumMessageItemSummary createForumMessageItemSummary()\n\t{\n\t\treturn new ForumMessageItemSummaryFake(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createGxsId(), IdFakes.createMsgId(), IdFakes.createMsgId(), IdFakes.createMsgId(), IdFakes.createGxsId(), Instant.now(), false);\n\t}\n\n\tpublic static ForumMessageItemSummary createForumMessageItemSummary(MsgId msgId, GxsId authorGxsId, MsgId parentMsgId)\n\t{\n\t\treturn new ForumMessageItemSummaryFake(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createGxsId(), msgId, IdFakes.createMsgId(), parentMsgId, authorGxsId, Instant.now(), false);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/ForumMessageItemSummaryFake.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.database.model.forum.ForumMessageItemSummary;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\nimport java.time.Instant;\nimport java.util.Objects;\n\npublic final class ForumMessageItemSummaryFake implements ForumMessageItemSummary\n{\n\tprivate final long id;\n\tprivate final String name;\n\tprivate final GxsId gxsId;\n\tprivate final MsgId msgId;\n\tprivate final MsgId originalMsgId;\n\tprivate final MsgId parentMsgId;\n\tprivate final GxsId authorGxsId;\n\tprivate final Instant published;\n\tprivate final boolean read;\n\n\tpublic ForumMessageItemSummaryFake(long id, String name, GxsId gxsId, MsgId msgId, MsgId originalMsgId, MsgId parentMsgId, GxsId authorGxsId, Instant published, boolean read)\n\t{\n\t\tthis.id = id;\n\t\tthis.name = name;\n\t\tthis.gxsId = gxsId;\n\t\tthis.msgId = msgId;\n\t\tthis.originalMsgId = originalMsgId;\n\t\tthis.parentMsgId = parentMsgId;\n\t\tthis.authorGxsId = authorGxsId;\n\t\tthis.published = published;\n\t\tthis.read = read;\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\t@Override\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\t@Override\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\t@Override\n\tpublic MsgId getOriginalMsgId()\n\t{\n\t\treturn originalMsgId;\n\t}\n\n\t@Override\n\tpublic MsgId getParentMsgId()\n\t{\n\t\treturn parentMsgId;\n\t}\n\n\t@Override\n\tpublic GxsId getAuthorGxsId()\n\t{\n\t\treturn authorGxsId;\n\t}\n\n\t@Override\n\tpublic Instant getPublished()\n\t{\n\t\treturn published;\n\t}\n\n\t@Override\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object obj)\n\t{\n\t\tif (obj == this) return true;\n\t\tif (obj == null || obj.getClass() != getClass()) return false;\n\t\tvar that = (ForumMessageItemSummaryFake) obj;\n\t\treturn id == that.id &&\n\t\t\t\tObjects.equals(name, that.name) &&\n\t\t\t\tObjects.equals(gxsId, that.gxsId) &&\n\t\t\t\tObjects.equals(msgId, that.msgId) &&\n\t\t\t\tObjects.equals(originalMsgId, that.originalMsgId) &&\n\t\t\t\tObjects.equals(parentMsgId, that.parentMsgId) &&\n\t\t\t\tObjects.equals(authorGxsId, that.authorGxsId) &&\n\t\t\t\tObjects.equals(published, that.published) &&\n\t\t\t\tread == that.read;\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(id, name, gxsId, msgId, originalMsgId, parentMsgId, authorGxsId, published, read);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ForumMessageItemSummaryFake[\" +\n\t\t\t\t\"id=\" + id + \", \" +\n\t\t\t\t\"name=\" + name + \", \" +\n\t\t\t\t\"gxsId=\" + gxsId + \", \" +\n\t\t\t\t\"msgId=\" + msgId + \", \" +\n\t\t\t\t\"originalMsgId=\" + originalMsgId + \", \" +\n\t\t\t\t\"parentMsgId=\" + parentMsgId + \", \" +\n\t\t\t\t\"authorMsgId=\" + authorGxsId + \", \" +\n\t\t\t\t\"published=\" + published + \", \" +\n\t\t\t\t\"read=\" + read + ']';\n\t}\n\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/GxsCircleTypeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.database.model.gxs.GxsCircleType.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass GxsCircleTypeTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, UNKNOWN.ordinal());\n\t\tassertEquals(1, PUBLIC.ordinal());\n\t\tassertEquals(2, EXTERNAL.ordinal());\n\t\tassertEquals(3, YOUR_FRIENDS_ONLY.ordinal());\n\t\tassertEquals(4, LOCAL.ordinal());\n\t\tassertEquals(5, EXTERNAL_SELF.ordinal());\n\t\tassertEquals(6, YOUR_EYES_ONLY.ordinal());\n\n\t\tassertEquals(7, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/GxsClientUpdateFakes.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class GxsClientUpdateFakes\n{\n\tprivate GxsClientUpdateFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static GxsClientUpdate createGxsClientUpdate()\n\t{\n\t\treturn createGxsClientUpdate(LocationFakes.createLocation(), ThreadLocalRandom.current().nextInt(1, 200));\n\t}\n\n\tpublic static GxsClientUpdate createGxsClientUpdate(Location location, int serviceType)\n\t{\n\t\treturn new GxsClientUpdate(location, serviceType, Instant.now());\n\t}\n\n\tpublic static GxsClientUpdate createGxsClientUpdateWithMessages(Location location, GxsId gxsId, Instant update, int serviceType)\n\t{\n\t\treturn new GxsClientUpdate(location, serviceType, gxsId, update);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/GxsPrivacyFlagsTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.database.model.gxs.GxsPrivacyFlags.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass GxsPrivacyFlagsTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, PRIVATE.ordinal());\n\t\tassertEquals(1, RESTRICTED.ordinal());\n\t\tassertEquals(2, PUBLIC.ordinal());\n\t\tassertEquals(8, SIGNED_ID.ordinal());\n\n\t\tassertEquals(9, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/GxsServiceSettingFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport java.time.Instant;\n\npublic final class GxsServiceSettingFakes\n{\n\tprivate GxsServiceSettingFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static GxsServiceSetting createGxsServiceSetting(int id, Instant lastUpdated)\n\t{\n\t\tvar gxsServiceSetting = new GxsServiceSetting(id, lastUpdated);\n\t\tgxsServiceSetting.setLastUpdated(lastUpdated);\n\t\treturn gxsServiceSetting;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/GxsSignatureFlagsTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.database.model.gxs.GxsSignatureFlags.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass GxsSignatureFlagsTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, ENCRYPTED.ordinal());\n\t\tassertEquals(1, ALL_SIGNED.ordinal());\n\t\tassertEquals(2, THREAD_HEAD.ordinal());\n\t\tassertEquals(3, NONE_REQUIRED.ordinal());\n\t\tassertEquals(4, UNUSED_1.ordinal());\n\t\tassertEquals(5, UNUSED_2.ordinal());\n\t\tassertEquals(6, UNUSED_3.ordinal());\n\t\tassertEquals(7, UNUSED_4.ordinal());\n\t\tassertEquals(8, ANTI_SPAM.ordinal());\n\t\tassertEquals(9, AUTHENTICATION_REQUIRED.ordinal());\n\t\tassertEquals(10, IF_NO_PUB_SIGN.ordinal());\n\t\tassertEquals(11, TRACK_MESSAGES.ordinal());\n\t\tassertEquals(12, ANTI_SPAM_2.ordinal());\n\n\t\tassertEquals(13, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/gxs/IdentityGroupItemFakes.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.gxs;\n\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\nimport java.util.EnumSet;\n\npublic final class IdentityGroupItemFakes\n{\n\tprivate IdentityGroupItemFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static IdentityGroupItem createIdentityGroupItem()\n\t{\n\t\treturn createIdentityGroupItem(IdFakes.createGxsId(), RandomStringUtils.insecure().nextAlphabetic(8));\n\t}\n\n\tpublic static IdentityGroupItem createIdentityGroupItem(GxsId gxsId, String name)\n\t{\n\t\tvar item = new IdentityGroupItem(gxsId, name);\n\t\titem.setDiffusionFlags(EnumSet.noneOf(GxsPrivacyFlags.class));\n\t\titem.setSignatureFlags(EnumSet.noneOf(GxsSignatureFlags.class));\n\t\titem.setProfileHash(new Sha1Sum(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}));\n\t\treturn item;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/identity/IdentityFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.identity;\n\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.identity.Type;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\n\nimport static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID;\n\npublic final class IdentityFakes\n{\n\tprivate IdentityFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static long id = OWN_IDENTITY_ID + 1;\n\n\tprivate static long getUniqueId()\n\t{\n\t\treturn id++;\n\t}\n\n\tpublic static IdentityGroupItem createOwn()\n\t{\n\t\treturn createOwn(StringFakes.createNickname());\n\t}\n\n\tpublic static IdentityGroupItem createOwn(String name)\n\t{\n\t\tvar identity = new IdentityGroupItem(IdFakes.createGxsId(), name);\n\t\tidentity.setId(1L);\n\t\tidentity.setType(Type.OWN);\n\t\treturn identity;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/identity/IdentityMapperTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.identity;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass IdentityMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(IdentityMapper.class);\n\t}\n\n\t@Test\n\tvoid toDTO_Success()\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar identityDTO = IdentityMapper.toDTO(identity);\n\n\t\tassertEquals(identity.getId(), identityDTO.id());\n\t\tassertEquals(identity.getName(), identityDTO.name());\n\t\tassertEquals(identity.getGxsId(), identityDTO.gxsId());\n\t\tassertEquals(identity.getPublished(), identityDTO.updated());\n\t\tassertEquals(identity.getType(), identityDTO.type());\n\t\tassertEquals(identity.hasImage(), identityDTO.hasImage());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/location/LocationFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.location;\n\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.NetMode;\nimport io.xeres.testutils.StringFakes;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\n\npublic final class LocationFakes\n{\n\tprivate LocationFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static long id = OWN_LOCATION_ID + 1;\n\n\tprivate static long getUniqueId()\n\t{\n\t\treturn id++;\n\t}\n\n\tpublic static Location createOwnLocation()\n\t{\n\t\treturn new Location(OWN_LOCATION_ID, StringFakes.createNickname(), ProfileFakes.createProfile(), new LocationIdentifier(getRandomArray()));\n\t}\n\n\tpublic static Location createLocation()\n\t{\n\t\treturn createLocation(StringFakes.createNickname(), ProfileFakes.createProfile(), new LocationIdentifier(getRandomArray()));\n\t}\n\n\tpublic static Location createLocation(String name, Profile profile)\n\t{\n\t\treturn createLocation(name, profile, new LocationIdentifier(getRandomArray()));\n\t}\n\n\tpublic static Location createFreshLocation(String name, Profile profile)\n\t{\n\t\tvar location = new Location(0L, name, profile, new LocationIdentifier(getRandomArray()));\n\t\tlocation.setNetMode(NetMode.UPNP);\n\t\tlocation.setVersion(\"Xeres 0.1.1\");\n\t\treturn location;\n\t}\n\n\tpublic static Location createLocation(String name, Profile profile, LocationIdentifier locationIdentifier)\n\t{\n\t\tvar location = new Location(getUniqueId(), name, profile, locationIdentifier);\n\t\tlocation.setNetMode(NetMode.UPNP);\n\t\tlocation.setVersion(\"Xeres 0.1.1\");\n\t\treturn location;\n\t}\n\n\tprivate static byte[] getRandomArray()\n\t{\n\t\tvar a = new byte[16];\n\t\tThreadLocalRandom.current().nextBytes(a);\n\t\treturn a;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/location/LocationMapperTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.location;\n\nimport io.xeres.app.database.model.connection.ConnectionFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.location.Availability;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Instant;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass LocationMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(LocationMapper.class);\n\t}\n\n\t@Test\n\tvoid toDTO_Success()\n\t{\n\t\tvar location = LocationFakes.createLocation(\"test\", ProfileFakes.createProfile(\"test\", 1));\n\t\tvar locationDTO = LocationMapper.toDTO(location);\n\n\t\tassertEquals(location.getId(), locationDTO.id());\n\t\tassertEquals(location.getName(), locationDTO.name());\n\t\tassertArrayEquals(location.getLocationIdentifier().getBytes(), locationDTO.locationIdentifier());\n\t\tassertEquals(location.isConnected(), locationDTO.connected());\n\t\tassertEquals(location.getLastConnected(), locationDTO.lastConnected());\n\t}\n\n\t@Test\n\tvoid toDeepDTO_Success()\n\t{\n\t\tvar location = LocationFakes.createLocation(\"test\", ProfileFakes.createProfile(\"test\", 1));\n\t\tlocation.addConnection(ConnectionFakes.createConnection());\n\n\t\tvar locationDTO = LocationMapper.toDeepDTO(location);\n\n\t\tassertEquals(location.getId(), locationDTO.id());\n\t\tassertEquals(location.getConnections().getFirst().getAddress(), locationDTO.connections().getFirst().address());\n\t}\n\n\t@Test\n\tvoid fromDTO_Success()\n\t{\n\t\tvar locationDTO = new LocationDTO(\n\t\t\t\t1L,\n\t\t\t\t\"test\",\n\t\t\t\tnew byte[16],\n\t\t\t\t\"foo\",\n\t\t\t\tnull,\n\t\t\t\ttrue,\n\t\t\t\tInstant.now(),\n\t\t\t\tAvailability.AVAILABLE,\n\t\t\t\t\"Xeres 2.3.2\"\n\t\t);\n\n\t\tvar location = LocationMapper.fromDTO(locationDTO);\n\n\t\tassertEquals(locationDTO.id(), location.getId());\n\t\tassertEquals(locationDTO.name(), location.getName());\n\t\tassertArrayEquals(locationDTO.locationIdentifier(), location.getLocationIdentifier().getBytes());\n\t\tassertEquals(locationDTO.connected(), location.isConnected());\n\t\tassertEquals(locationDTO.lastConnected(), location.getLastConnected());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/profile/ProfileFakes.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.profile;\n\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.testutils.StringFakes;\n\nimport java.time.Instant;\nimport java.util.concurrent.ThreadLocalRandom;\n\nimport static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID;\n\npublic final class ProfileFakes\n{\n\tprivate ProfileFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static long id = OWN_PROFILE_ID + 1;\n\n\tprivate static long getUniqueId()\n\t{\n\t\treturn id++;\n\t}\n\n\tpublic static Profile createProfile()\n\t{\n\t\treturn createProfile(StringFakes.createNickname(), ThreadLocalRandom.current().nextLong());\n\t}\n\n\tpublic static Profile createFreshProfile(String name, long pgpIdentifier)\n\t{\n\t\treturn new Profile(0L, name, pgpIdentifier, Instant.now(), new ProfileFingerprint(getRandomArray(20)), getRandomArray(200));\n\t}\n\n\tpublic static Profile createProfile(String name, long pgpIdentifier)\n\t{\n\t\treturn createProfile(name, pgpIdentifier, new ProfileFingerprint(getRandomArray(20)), getRandomArray(200));\n\t}\n\n\tpublic static Profile createProfile(String name, long pgpIdentifier, byte[] pgpFingerprint, byte[] data)\n\t{\n\t\treturn new Profile(getUniqueId(), name, pgpIdentifier, Instant.now(), new ProfileFingerprint(pgpFingerprint), data);\n\t}\n\n\tpublic static Profile createProfile(String name, long pgpIdentifier, ProfileFingerprint profileFingerprint, byte[] data)\n\t{\n\t\treturn new Profile(getUniqueId(), name, pgpIdentifier, Instant.now(), profileFingerprint, data);\n\t}\n\n\tpublic static Profile createOwnProfile()\n\t{\n\t\treturn new Profile(1L, StringFakes.createNickname(), ThreadLocalRandom.current().nextLong(), Instant.now(), new ProfileFingerprint(getRandomArray(20)), getRandomArray(200));\n\t}\n\n\tprivate static byte[] getRandomArray(int size)\n\t{\n\t\tvar a = new byte[size];\n\t\tThreadLocalRandom.current().nextBytes(a);\n\t\treturn a;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/profile/ProfileMapperTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.profile;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Instant;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ProfileMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ProfileMapper.class);\n\t}\n\n\t@Test\n\tvoid toDTO_Success()\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"test\", 1);\n\t\tvar profileDTO = ProfileMapper.toDTO(profile);\n\n\t\tassertEquals(profile.getId(), profileDTO.id());\n\t\tassertEquals(profile.getName(), profileDTO.name());\n\t\tassertEquals(profile.getPgpIdentifier(), Long.parseLong(profileDTO.pgpIdentifier()));\n\t\tassertArrayEquals(profile.getProfileFingerprint().getBytes(), profileDTO.pgpFingerprint());\n\t\tassertArrayEquals(profile.getPgpPublicKeyData(), profileDTO.pgpPublicKeyData());\n\t\tassertEquals(profile.isAccepted(), profileDTO.accepted());\n\t\tassertEquals(profile.getTrust(), profileDTO.trust());\n\t}\n\n\t@Test\n\tvoid toDeepDTO_Success()\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"test\", 1);\n\t\tprofile.addLocation(LocationFakes.createLocation(\"foo\", profile));\n\n\t\tvar profileDTO = ProfileMapper.toDeepDTO(profile);\n\n\t\tassertEquals(profile.getId(), profileDTO.id());\n\t\tassertEquals(profile.getLocations().getFirst().getId(), profileDTO.locations().getFirst().id());\n\t}\n\n\t@Test\n\tvoid fromDTO_Success()\n\t{\n\t\tvar profileDTO = new ProfileDTO(\n\t\t\t\t1L,\n\t\t\t\t\"prout\",\n\t\t\t\t\"2\",\n\t\t\t\tInstant.now(),\n\t\t\t\tnew byte[20],\n\t\t\t\tnew byte[4],\n\t\t\t\ttrue,\n\t\t\t\tTrust.ULTIMATE,\n\t\t\t\tnull\n\t\t);\n\n\t\tvar profile = ProfileMapper.fromDTO(profileDTO);\n\n\t\tassertEquals(profileDTO.id(), profile.getId());\n\t\tassertEquals(profileDTO.name(), profile.getName());\n\t\tassertEquals(profileDTO.pgpIdentifier(), String.valueOf(profile.getPgpIdentifier()));\n\t\tassertArrayEquals(profileDTO.pgpFingerprint(), profile.getProfileFingerprint().getBytes());\n\t\tassertArrayEquals(profileDTO.pgpPublicKeyData(), profile.getPgpPublicKeyData());\n\t\tassertEquals(profileDTO.accepted(), profile.isAccepted());\n\t\tassertEquals(profileDTO.trust(), profile.getTrust());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/settings/SettingsFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.settings;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class SettingsFakes\n{\n\tprivate SettingsFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Settings createSettings()\n\t{\n\t\tvar settings = new Settings();\n\t\tsettings.setPgpPrivateKeyData(getRandomArray(2000));\n\t\tsettings.setLocationPrivateKeyData(getRandomArray(2000));\n\t\tsettings.setLocationPublicKeyData(getRandomArray(500));\n\t\tsettings.setLocationCertificate(getRandomArray(200));\n\t\treturn settings;\n\t}\n\n\tprivate static byte[] getRandomArray(int size)\n\t{\n\t\tvar a = new byte[size];\n\t\tThreadLocalRandom.current().nextBytes(a);\n\t\treturn a;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/model/share/ShareFakes.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.model.share;\n\nimport io.xeres.app.database.model.file.File;\nimport io.xeres.app.database.model.file.FileFakes;\n\nimport java.nio.file.Path;\n\npublic final class ShareFakes\n{\n\tprivate ShareFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Share createShare(Path path)\n\t{\n\t\tFile file = FileFakes.createFile(path.getRoot().toString(), null);\n\n\t\tfor (Path component : path)\n\t\t{\n\t\t\tfile = FileFakes.createFile(component.getFileName().toString(), file);\n\t\t}\n\t\treturn createShare(file);\n\t}\n\n\tpublic static Share createShare(File file)\n\t{\n\t\tvar share = new Share();\n\t\tshare.setFile(file);\n\t\treturn share;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/ChatRoomRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.chat.ChatRoomFakes;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass ChatRoomRepositoryTest\n{\n\t@Autowired\n\tprivate ChatRoomRepository chatRoomRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar identity = IdentityFakes.createOwn();\n\n\t\tvar chatRoom1 = ChatRoomFakes.createChatRoomEntity(identity);\n\t\tvar chatRoom2 = ChatRoomFakes.createChatRoomEntity(identity);\n\t\tvar chatRoom3 = ChatRoomFakes.createChatRoomEntity(identity);\n\n\t\tchatRoom1.setSubscribed(true);\n\t\tchatRoom2.setSubscribed(true);\n\t\tchatRoom3.setSubscribed(false);\n\n\t\tvar savedChatRoom1 = chatRoomRepository.save(chatRoom1);\n\t\tchatRoomRepository.save(chatRoom2);\n\t\tchatRoomRepository.save(chatRoom3);\n\n\t\tvar chatRooms = chatRoomRepository.findAllBySubscribedTrueAndJoinedFalse();\n\t\tassertNotNull(chatRooms);\n\t\tassertEquals(2, chatRooms.size());\n\n\t\tvar first = chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom1.getRoomId(), identity).orElse(null);\n\n\t\tassertNotNull(first);\n\t\tassertEquals(savedChatRoom1.getId(), first.getId());\n\t\tassertEquals(savedChatRoom1.getName(), first.getName());\n\n\t\tfirst.setJoined(true);\n\n\t\tvar updatedChatRoom = chatRoomRepository.save(first);\n\n\t\tassertNotNull(updatedChatRoom);\n\t\tassertEquals(first.getId(), updatedChatRoom.getId());\n\t\tassertTrue(updatedChatRoom.isJoined());\n\n\t\tchatRoomRepository.deleteById(first.getId());\n\n\t\tvar deleted = chatRoomRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/FileRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.file.FileFakes;\nimport io.xeres.common.file.FileType;\nimport io.xeres.testutils.Sha1SumFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass FileRepositoryTest\n{\n\t@Autowired\n\tprivate FileRepository fileRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar file1 = FileFakes.createFile(\"foo\", null);\n\t\tvar file2 = FileFakes.createFile(\"bar\", null);\n\t\tvar file3 = FileFakes.createFile(\"plop\", null);\n\n\t\tvar savedFile = fileRepository.save(file1);\n\t\tfileRepository.save(file2);\n\t\tfileRepository.save(file3);\n\n\t\tvar files = fileRepository.findAll();\n\t\tassertNotNull(files);\n\t\tassertEquals(3, files.size());\n\n\t\tvar first = fileRepository.findById(files.getFirst().getId()).orElse(null);\n\n\t\tassertNotNull(first);\n\t\tassertEquals(savedFile.getId(), first.getId());\n\t\tassertEquals(savedFile.getName(), first.getName());\n\n\t\tfirst.setType(FileType.VIDEO);\n\n\t\tvar updatedFile = fileRepository.save(first);\n\n\t\tassertNotNull(updatedFile);\n\t\tassertEquals(first.getId(), updatedFile.getId());\n\t\tassertEquals(FileType.VIDEO, updatedFile.getType());\n\n\t\tfileRepository.deleteById(first.getId());\n\n\t\tvar deleted = fileRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n\n\t@Test\n\tvoid FindByHash_Success()\n\t{\n\t\tvar hash = Sha1SumFakes.createSha1Sum();\n\t\tvar file = FileFakes.createFile(\"foo\", null);\n\t\tfile.setHash(hash);\n\t\tfileRepository.save(file);\n\n\t\tvar found = fileRepository.findByHash(hash).getFirst();\n\t\tassertNotNull(found);\n\t}\n\n\t@Test\n\tvoid FindByEncryptedHash_Success()\n\t{\n\t\tvar hash = Sha1SumFakes.createSha1Sum();\n\t\tvar file = FileFakes.createFile(\"foo\", null);\n\t\tfile.setEncryptedHash(hash);\n\t\tfileRepository.save(file);\n\n\t\tvar found = fileRepository.findByEncryptedHash(hash).getFirst();\n\t\tassertNotNull(found);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/GxsClientUpdateRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.GxsClientUpdateFakes;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.testutils.IdFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport java.time.Instant;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass GxsClientUpdateRepositoryTest\n{\n\t@Autowired\n\tprivate ProfileRepository profileRepository;\n\n\t@Autowired\n\tprivate GxsClientUpdateRepository gxsClientUpdateRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar profile = ProfileFakes.createFreshProfile(\"profile1\", 1);\n\t\tprofile = profileRepository.save(profile);\n\t\tvar location = LocationFakes.createFreshLocation(\"location1\", profile);\n\n\t\tprofile.addLocation(location);\n\t\tprofile = profileRepository.save(profile);\n\n\t\tvar gxsClientUpdate1 = GxsClientUpdateFakes.createGxsClientUpdate(profile.getLocations().getFirst(), 200);\n\t\tvar gxsClientUpdate2 = GxsClientUpdateFakes.createGxsClientUpdate(profile.getLocations().getFirst(), 201);\n\t\tvar gxsClientUpdate3 = GxsClientUpdateFakes.createGxsClientUpdate(profile.getLocations().getFirst(), 202);\n\n\t\tvar savedGxsClientUpdate1 = gxsClientUpdateRepository.save(gxsClientUpdate1);\n\t\tvar savedGxsClientUpdate2 = gxsClientUpdateRepository.save(gxsClientUpdate2);\n\t\tgxsClientUpdateRepository.save(gxsClientUpdate3);\n\n\t\tvar gxsClientUpdates = gxsClientUpdateRepository.findAll();\n\t\tassertNotNull(gxsClientUpdates);\n\t\tassertEquals(3, gxsClientUpdates.size());\n\n\t\tvar first = gxsClientUpdateRepository.findById(gxsClientUpdates.getFirst().getId()).orElse(null);\n\t\tassertNotNull(first);\n\t\tassertEquals(savedGxsClientUpdate1.getId(), first.getId());\n\t\tassertEquals(savedGxsClientUpdate1.getServiceType(), first.getServiceType());\n\n\t\tvar second = gxsClientUpdateRepository.findByLocationAndServiceType(gxsClientUpdate2.getLocation(), gxsClientUpdate2.getServiceType()).orElse(null);\n\t\tassertNotNull(second);\n\t\tassertEquals(savedGxsClientUpdate2.getId(), second.getId());\n\t\tassertEquals(savedGxsClientUpdate2.getServiceType(), second.getServiceType());\n\n\t\tfirst.setServiceType(300);\n\n\t\tvar updatedGxsClientUpdate = gxsClientUpdateRepository.save(first);\n\n\t\tassertNotNull(updatedGxsClientUpdate);\n\t\tassertEquals(first.getId(), updatedGxsClientUpdate.getId());\n\t\tassertEquals(300, updatedGxsClientUpdate.getServiceType());\n\n\t\tgxsClientUpdateRepository.deleteById(first.getId());\n\n\t\tvar deleted = gxsClientUpdateRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n\n\t@Test\n\tvoid CRUD_Messages_Success()\n\t{\n\t\tvar profile = ProfileFakes.createFreshProfile(\"profile1\", 1);\n\t\tprofile = profileRepository.save(profile);\n\t\tvar location = LocationFakes.createFreshLocation(\"location1\", profile);\n\n\t\tprofile.addLocation(location);\n\t\tprofile = profileRepository.save(profile);\n\n\t\tvar gxsId1 = IdFakes.createGxsId();\n\t\tvar time1 = \"2007-12-03T10:15:30.00Z\";\n\t\tvar update1 = Instant.parse(time1);\n\t\tvar gxsId2 = IdFakes.createGxsId();\n\t\tvar time2 = \"2014-11-05T09:28:35.00Z\";\n\t\tvar update2 = Instant.parse(time2);\n\t\tvar gxsId3 = IdFakes.createGxsId();\n\t\tvar time3 = \"2021-01-01T14:45:00.00Z\";\n\t\tvar update3 = Instant.parse(time3);\n\n\t\tvar gxsClientUpdate = GxsClientUpdateFakes.createGxsClientUpdateWithMessages(profile.getLocations().getFirst(), gxsId1, update1, 200);\n\t\tgxsClientUpdate.putMessageUpdate(gxsId2, update2);\n\t\tgxsClientUpdate.putMessageUpdate(gxsId3, update3);\n\n\t\tvar savedGxsClientUpdate = gxsClientUpdateRepository.save(gxsClientUpdate);\n\n\t\tvar gxsClientUpdates = gxsClientUpdateRepository.findAll();\n\t\tassertNotNull(gxsClientUpdates);\n\t\tassertEquals(1, gxsClientUpdates.size());\n\n\t\tvar first = gxsClientUpdateRepository.findById(gxsClientUpdates.getFirst().getId()).orElse(null);\n\t\tassertNotNull(first);\n\t\tassertEquals(savedGxsClientUpdate.getId(), first.getId());\n\t\tassertEquals(savedGxsClientUpdate.getServiceType(), first.getServiceType());\n\t\tassertEquals(update1, first.getMessageUpdate(gxsId1));\n\t\tassertEquals(update2, first.getMessageUpdate(gxsId2));\n\t\tassertEquals(update3, first.getMessageUpdate(gxsId3));\n\n\t\tfirst.removeMessageUpdate(gxsId3);\n\n\t\tvar updatedGxsClientUpdate = gxsClientUpdateRepository.save(first);\n\n\t\tassertNotNull(updatedGxsClientUpdate);\n\n\t\tassertNull(first.getMessageUpdate(gxsId3));\n\t\tassertEquals(update1, first.getMessageUpdate(gxsId1));\n\t\tassertEquals(update2, first.getMessageUpdate(gxsId2));\n\n\t\tgxsClientUpdateRepository.deleteById(first.getId());\n\n\t\tvar deleted = gxsClientUpdateRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/GxsIdentityRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.IdentityGroupItemFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass GxsIdentityRepositoryTest\n{\n\t@Autowired\n\tprivate GxsIdentityRepository gxsIdentityRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar gxsIdGroupItem1 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsIdGroupItem2 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsIdGroupItem3 = IdentityGroupItemFakes.createIdentityGroupItem();\n\n\t\tvar savedGxsIdGroupItem1 = gxsIdentityRepository.save(gxsIdGroupItem1);\n\t\tvar savedGxsIdGroupItem2 = gxsIdentityRepository.save(gxsIdGroupItem2);\n\t\tgxsIdentityRepository.save(gxsIdGroupItem3);\n\n\t\tvar gxsIdGroupItems = gxsIdentityRepository.findAll();\n\t\tassertNotNull(gxsIdGroupItems);\n\t\tassertEquals(3, gxsIdGroupItems.size());\n\n\t\tvar first = gxsIdentityRepository.findById(gxsIdGroupItems.getFirst().getId()).orElse(null);\n\t\tassertNotNull(first);\n\t\tassertEquals(savedGxsIdGroupItem1.getId(), first.getId());\n\t\tassertEquals(savedGxsIdGroupItem1.getName(), first.getName());\n\n\t\tvar second = gxsIdentityRepository.findByGxsId(gxsIdGroupItem2.getGxsId()).orElse(null);\n\t\tassertNotNull(second);\n\t\tassertEquals(savedGxsIdGroupItem2.getId(), second.getId());\n\t\tassertEquals(savedGxsIdGroupItem2.getName(), second.getName());\n\n\t\tfirst.setIdentityScore(10);\n\n\t\tvar updatedGxsIdGroupItem = gxsIdentityRepository.save(first);\n\n\t\tassertNotNull(updatedGxsIdGroupItem);\n\t\tassertEquals(first.getId(), updatedGxsIdGroupItem.getId());\n\t\tassertEquals(10, updatedGxsIdGroupItem.getIdentityScore());\n\n\t\tgxsIdentityRepository.deleteById(first.getId());\n\n\t\tvar deleted = gxsIdentityRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/GxsServiceSettingRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.gxs.GxsServiceSettingFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass GxsServiceSettingRepositoryTest\n{\n\t@Autowired\n\tprivate GxsServiceSettingRepository gxsServiceSettingRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar instantEpoch = Instant.EPOCH;\n\t\tvar instantNow = Instant.now();\n\t\tvar instantYesterday = instantNow.minus(1, ChronoUnit.DAYS);\n\n\t\tvar gxsServiceSetting1 = GxsServiceSettingFakes.createGxsServiceSetting(1, instantEpoch);\n\t\tvar gxsServiceSetting2 = GxsServiceSettingFakes.createGxsServiceSetting(2, instantYesterday);\n\t\tvar gxsServiceSetting3 = GxsServiceSettingFakes.createGxsServiceSetting(3, instantNow);\n\n\t\tvar savedGxsServiceSetting1 = gxsServiceSettingRepository.save(gxsServiceSetting1);\n\t\tvar savedGxsServiceSetting2 = gxsServiceSettingRepository.save(gxsServiceSetting2);\n\t\tgxsServiceSettingRepository.save(gxsServiceSetting3);\n\n\t\tvar gxsServiceSettings = gxsServiceSettingRepository.findAll();\n\t\tassertNotNull(gxsServiceSettings);\n\t\tassertEquals(3, gxsServiceSettings.size());\n\n\t\tvar first = gxsServiceSettingRepository.findById(gxsServiceSettings.getFirst().getId()).orElse(null);\n\t\tassertNotNull(first);\n\t\tassertEquals(savedGxsServiceSetting1.getId(), first.getId());\n\t\tassertEquals(savedGxsServiceSetting1.getLastUpdated(), first.getLastUpdated());\n\n\t\tvar second = gxsServiceSettingRepository.findById(savedGxsServiceSetting2.getId()).orElse(null);\n\t\tassertNotNull(second);\n\t\tassertEquals(savedGxsServiceSetting2.getId(), second.getId());\n\t\tassertEquals(savedGxsServiceSetting2.getLastUpdated(), second.getLastUpdated());\n\n\t\tfirst.setLastUpdated(instantNow.plus(1, ChronoUnit.DAYS));\n\n\t\tvar updatedGxsServiceSetting = gxsServiceSettingRepository.save(first);\n\n\t\tassertNotNull(updatedGxsServiceSetting);\n\t\tassertEquals(first.getId(), updatedGxsServiceSetting.getId());\n\t\tassertEquals(instantNow.plus(1, ChronoUnit.DAYS), updatedGxsServiceSetting.getLastUpdated());\n\n\t\tgxsServiceSettingRepository.deleteById(first.getId());\n\n\t\tvar deleted = gxsServiceSettingRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/LocationRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass LocationRepositoryTest\n{\n\t@Autowired\n\tprivate ProfileRepository profileRepository;\n\t@Autowired\n\tprivate LocationRepository locationRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar profile = ProfileFakes.createFreshProfile(\"test\", 1);\n\n\t\tprofile = profileRepository.save(profile);\n\n\t\tvar location1 = LocationFakes.createFreshLocation(\"test1\", profile);\n\t\tvar location2 = LocationFakes.createFreshLocation(\"test2\", profile);\n\t\tvar location3 = LocationFakes.createFreshLocation(\"test3\", profile);\n\n\t\tprofile.addLocation(location1);\n\t\tprofile.addLocation(location2);\n\t\tprofile.addLocation(location3);\n\n\t\tprofileRepository.save(profile);\n\n\t\tvar locations = locationRepository.findAll();\n\t\tassertNotNull(locations);\n\t\tassertEquals(3, locations.size());\n\n\t\tvar first = locationRepository.findById(locations.getFirst().getId()).orElse(null);\n\n\t\tassertNotNull(first);\n\t\tassertEquals(locations.getFirst().getId(), first.getId());\n\t\tassertEquals(locations.getFirst().getName(), first.getName());\n\n\t\tfirst.setConnected(true);\n\n\t\tvar updatedLocation = locationRepository.save(first);\n\n\t\tassertNotNull(updatedLocation);\n\t\tassertEquals(first.getId(), updatedLocation.getId());\n\t\tassertTrue(updatedLocation.isConnected());\n\n\t\tlocationRepository.deleteById(first.getId());\n\n\t\tvar deleted = locationRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\n\t\tprofileRepository.deleteById(profile.getId());\n\t\tdeleted = locationRepository.findById(location2.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/ProfileRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass ProfileRepositoryTest\n{\n\t@Autowired\n\tprivate ProfileRepository profileRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar profile1 = ProfileFakes.createFreshProfile(\"test1\", 1);\n\t\tvar profile2 = ProfileFakes.createFreshProfile(\"test2\", 2);\n\t\tvar profile3 = ProfileFakes.createFreshProfile(\"test3\", 3);\n\n\t\tvar savedProfile = profileRepository.save(profile1);\n\t\tprofileRepository.save(profile2);\n\t\tprofileRepository.save(profile3);\n\n\t\tvar profiles = profileRepository.findAll();\n\t\tassertNotNull(profiles);\n\t\tassertEquals(3, profiles.size());\n\n\t\tvar first = profileRepository.findById(profiles.getFirst().getId()).orElse(null);\n\n\t\tassertNotNull(first);\n\t\tassertEquals(savedProfile.getId(), first.getId());\n\t\tassertEquals(savedProfile.getName(), first.getName());\n\n\t\tfirst.setAccepted(false);\n\n\t\tvar updatedProfile = profileRepository.save(first);\n\n\t\tassertNotNull(updatedProfile);\n\t\tassertEquals(first.getId(), updatedProfile.getId());\n\t\tassertFalse(updatedProfile.isAccepted());\n\n\t\tprofileRepository.deleteById(first.getId());\n\n\t\tvar deleted = profileRepository.findById(first.getId());\n\t\tassertTrue(deleted.isEmpty());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/database/repository/SettingsRepositoryTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.database.repository;\n\nimport io.xeres.app.database.model.settings.SettingsFakes;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DataJpaTest\nclass SettingsRepositoryTest\n{\n\t@Autowired\n\tprivate SettingsRepository settingsRepository;\n\n\t@Test\n\tvoid CRUD_Success()\n\t{\n\t\tvar prefs = SettingsFakes.createSettings();\n\t\tvar unwantedPrefs = SettingsFakes.createSettings();\n\n\t\tvar savedPrefs = settingsRepository.save(prefs);\n\t\tsettingsRepository.save(unwantedPrefs);\n\n\t\tvar prefsList = settingsRepository.findAll();\n\t\tassertNotNull(prefsList);\n\t\tassertEquals(1, prefsList.size());\n\n\t\tvar first = settingsRepository.findById((byte) 1).orElse(null);\n\n\t\tassertNotNull(first);\n\t\tassertArrayEquals(savedPrefs.getPgpPrivateKeyData(), first.getPgpPrivateKeyData());\n\n\t\tfirst.setPgpPrivateKeyData(new byte[]{1});\n\n\t\tvar updatedPrefs = settingsRepository.save(first);\n\n\t\tassertNotNull(updatedPrefs);\n\t\tassertArrayEquals(first.getPgpPrivateKeyData(), updatedPrefs.getPgpPrivateKeyData());\n\n\t\tsettingsRepository.deleteById((byte) 1);\n\n\t\tvar deleted = settingsRepository.findById((byte) 1);\n\t\tassertTrue(deleted.isEmpty());\n\n\t\t// And then save again to make sure the ID stays at 1\n\t\tsettingsRepository.save(prefs);\n\n\t\tprefsList = settingsRepository.findAll();\n\t\tassertNotNull(prefsList);\n\t\tassertEquals(1, prefsList.size());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/environment/CloudTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.environment;\n\nimport io.xeres.app.application.environment.Cloud;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass CloudTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(Cloud.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/environment/CommandArgumentTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.environment;\n\nimport io.xeres.app.application.environment.CommandArgument;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass CommandArgumentTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(CommandArgument.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/environment/HostVariableTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.environment;\n\nimport io.xeres.app.application.environment.HostVariable;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass HostVariableTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(HostVariable.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/job/IdleDetectionJobTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.service.PeerService;\nimport io.xeres.app.xrs.service.status.IdleChecker;\nimport io.xeres.app.xrs.service.status.StatusRsService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static io.xeres.common.location.Availability.AVAILABLE;\nimport static io.xeres.common.location.Availability.AWAY;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass IdleDetectionJobTest\n{\n\t@Mock\n\tprivate PeerService peerService;\n\n\t@Mock\n\tprivate StatusRsService statusRsService;\n\n\t@Mock\n\tprivate IdleChecker idleChecker;\n\n\t@InjectMocks\n\tprivate IdleDetectionJob idleDetectionJob;\n\n\t@Test\n\tvoid IsOnline_Automatic_Success()\n\t{\n\t\twhen(peerService.isRunning()).thenReturn(true);\n\t\twhen(idleChecker.getIdleTime()).thenReturn(0);\n\n\t\tidleDetectionJob.checkIdle();\n\n\t\tverify(statusRsService).changeAvailabilityAutomatically(argThat(status -> {\n\t\t\tassertEquals(AVAILABLE, status);\n\t\t\treturn true;\n\t\t}));\n\t}\n\n\t@Test\n\tvoid IsAway_Automatic_Success()\n\t{\n\t\twhen(peerService.isRunning()).thenReturn(true);\n\t\twhen(idleChecker.getIdleTime()).thenReturn(60 * 5 + 1);\n\n\t\tidleDetectionJob.checkIdle();\n\n\t\tverify(statusRsService).changeAvailabilityAutomatically(argThat(status -> {\n\t\t\tassertEquals(AWAY, status);\n\t\t\treturn true;\n\t\t}));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/job/PeerConnectionJobTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.job;\n\nimport io.xeres.app.database.model.connection.ConnectionFakes;\nimport io.xeres.app.net.peer.bootstrap.PeerI2pClient;\nimport io.xeres.app.net.peer.bootstrap.PeerTcpClient;\nimport io.xeres.app.net.peer.bootstrap.PeerTorClient;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.PeerService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.List;\n\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass PeerConnectionJobTest\n{\n\t@Mock\n\tprivate PeerService peerService;\n\n\t@Mock\n\tprivate LocationService locationService;\n\n\t@Mock\n\tprivate PeerTcpClient peerTcpClient;\n\n\t@Mock\n\tprivate PeerTorClient peerTorClient;\n\n\t@Mock\n\tprivate PeerI2pClient peerI2pClient;\n\n\t@InjectMocks\n\tprivate PeerConnectionJob peerConnectionJob;\n\n\t@Test\n\tvoid IsNotRunning_Success()\n\t{\n\t\twhen(peerService.isRunning()).thenReturn(false);\n\n\t\tpeerConnectionJob.checkConnections();\n\n\t\tverify(peerService).isRunning();\n\t\tverify(locationService, never()).getConnectionsToConnectTo(anyInt());\n\t}\n\n\t@Test\n\tvoid ConnectToPeers_TCP_Success()\n\t{\n\t\twhen(peerService.isRunning()).thenReturn(true);\n\t\twhen(locationService.getConnectionsToConnectTo(anyInt())).thenReturn(List.of(ConnectionFakes.createConnection(PeerAddress.Type.IPV4, \"1.1.1.1:1234\", true)));\n\n\t\tpeerConnectionJob.checkConnections();\n\n\t\tverify(peerService).isRunning();\n\t\tverify(locationService).getConnectionsToConnectTo(anyInt());\n\t\tverify(peerTcpClient).connect(any(PeerAddress.class));\n\t}\n\n\t@Test\n\tvoid ConnectToPeers_Tor_Success()\n\t{\n\t\twhen(peerService.isRunning()).thenReturn(true);\n\t\twhen(locationService.getConnectionsToConnectTo(anyInt())).thenReturn(List.of(ConnectionFakes.createConnection(PeerAddress.Type.TOR, \"2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:80\", true)));\n\n\t\tpeerConnectionJob.checkConnections();\n\n\t\tverify(peerService).isRunning();\n\t\tverify(locationService).getConnectionsToConnectTo(anyInt());\n\t\tverify(peerTorClient).connect(any(PeerAddress.class));\n\t}\n\n\t@Test\n\tvoid ConnectToPeers_I2p_Success()\n\t{\n\t\twhen(peerService.isRunning()).thenReturn(true);\n\t\twhen(locationService.getConnectionsToConnectTo(anyInt())).thenReturn(List.of(ConnectionFakes.createConnection(PeerAddress.Type.TOR, \"udhdrtrcetjm5sxzskjyr5ztpeszydbh4dpl3pl4utgqqw2v4jna.b32.i2p:80\", true)));\n\n\t\tpeerConnectionJob.checkConnections();\n\n\t\tverify(peerService).isRunning();\n\t\tverify(locationService).getConnectionsToConnectTo(anyInt());\n\t\tverify(peerI2pClient).connect(any(PeerAddress.class));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/bdisc/BroadcastDiscoveryServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.bdisc;\n\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.common.protocol.ip.IP;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.time.Duration;\nimport java.util.Optional;\n\nimport static org.awaitility.Awaitility.await;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass BroadcastDiscoveryServiceTest\n{\n\t@Mock\n\tprivate LocationService locationService;\n\n\t@Mock\n\tprivate DatabaseSessionManager databaseSessionManager;\n\n\t@InjectMocks\n\tprivate BroadcastDiscoveryService broadcastDiscoveryService;\n\n\t@Test\n\tvoid StartStop_Success()\n\t{\n\t\tvar ownLocation = LocationFakes.createOwnLocation();\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation));\n\n\t\tbroadcastDiscoveryService.start(IP.getLocalIpAddress(), 36406); // nothing should reply in there, hopefully. We can't use localhost because linux has no broadcast in it\n\t\tawait().atMost(Duration.ofSeconds(10)).until(() -> broadcastDiscoveryService.isRunning());\n\n\t\tbroadcastDiscoveryService.stop();\n\t\tbroadcastDiscoveryService.waitForTermination();\n\t\tassertFalse(broadcastDiscoveryService.isRunning());\n\t\tverify(locationService).findOwnLocation();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/bdisc/UdpDiscoveryProtocolTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.bdisc;\n\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.net.InetSocketAddress;\nimport java.nio.ByteBuffer;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass UdpDiscoveryProtocolTest\n{\n\tprivate static final int APP_ID = 904571;\n\tprivate static final int PEER_ID = 1730783293;\n\tprivate static final int PACKET_INDEX = 32921;\n\tprivate static final UdpDiscoveryPeer.Status STATUS_PRESENT = UdpDiscoveryPeer.Status.PRESENT;\n\tprivate static final ProfileFingerprint FINGERPRINT = new ProfileFingerprint(Id.toBytes(\"54B7C121B73E434539DC3E0BA87461B115390F34\"));\n\tprivate static final LocationIdentifier LOCATION_ID = new LocationIdentifier(Id.toBytes(\"ec65a805a3faa6d4b88e7a2ee5a45f33\"));\n\tprivate static final String LOCAL_IP = \"127.0.0.1\";\n\tprivate static final int LOCAL_PORT = 8600;\n\tprivate static final String PROFILE_NAME = \"retroshare.ch\";\n\n\tprivate static final String DATA = \"524e36550000000000000dcd7b6729a83d00008099000037000054b7c121b73e434539dc3e0ba87461b115390f34ec65a805a3faa6d4b88e7a2ee5a45f3321980000000d726574726f73686172652e6368\";\n\n\tprivate static final String DATA_NEW = \"534f37560100000000000dcd7b6729a83d0000000000008099003754b7c121b73e434539dc3e0ba87461b115390f34ec65a805a3faa6d4b88e7a2ee5a45f3321980000000d726574726f73686172652e6368\";\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(UdpDiscoveryProtocol.class);\n\t}\n\n\t@Test\n\tvoid ParsePacket_Success()\n\t{\n\t\tvar peer = UdpDiscoveryProtocol.parsePacket(ByteBuffer.wrap(Id.toBytes(DATA)), new InetSocketAddress(LOCAL_IP, 6666));\n\n\t\tassertNotNull(peer);\n\t\tassertEquals(APP_ID, peer.getAppId());\n\t\tassertEquals(PEER_ID, peer.getPeerId());\n\t\tassertEquals(PACKET_INDEX, peer.getPacketIndex());\n\t\tassertEquals(STATUS_PRESENT, peer.getStatus());\n\t\tassertEquals(FINGERPRINT, peer.getFingerprint());\n\t\tassertEquals(LOCATION_ID, peer.getLocationIdentifier());\n\t\tassertEquals(LOCAL_IP, peer.getIpAddress());\n\t\tassertEquals(LOCAL_PORT, peer.getLocalPort());\n\t\tassertEquals(PROFILE_NAME, peer.getProfileName());\n\t}\n\n\t@Test\n\tvoid CreatePacket_Success()\n\t{\n\t\tvar data = UdpDiscoveryProtocol.createPacket(\n\t\t\t\t512,\n\t\t\t\tSTATUS_PRESENT,\n\t\t\t\tAPP_ID,\n\t\t\t\tPEER_ID,\n\t\t\t\tPACKET_INDEX,\n\t\t\t\tFINGERPRINT,\n\t\t\t\tLOCATION_ID,\n\t\t\t\tLOCAL_PORT,\n\t\t\t\tPROFILE_NAME);\n\n\t\tvar a = new byte[data.position()];\n\t\tdata.flip();\n\t\tdata.get(a);\n\t\tassertArrayEquals(Id.toBytes(DATA_NEW), a);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/dht/NodeIdTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.dht;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\n\nclass NodeIdTest\n{\n\t@Test\n\tvoid Create_Success()\n\t{\n\t\tvar locationIdentifier = new LocationIdentifier(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16});\n\n\t\tvar result = NodeId.create(locationIdentifier);\n\n\t\tassertArrayEquals(new byte[]{2, -90, -53, -104, 57, 58, 24, -74, 10, 11, 61, 1, -120, 28, -26, 84, 78, 6, 91, 59}, result);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/AbstractPipelineTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.buffer.ByteBuf;\n\npublic abstract class AbstractPipelineTest\n{\n\tstatic byte[] getByteBufAsArray(ByteBuf buf)\n\t{\n\t\tbuf.markReaderIndex();\n\t\tbuf.readerIndex(0);\n\t\tvar out = new byte[buf.writerIndex()];\n\t\tbuf.readBytes(out);\n\t\tbuf.resetReaderIndex();\n\t\treturn out;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/ChannelFake.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.buffer.ByteBufAllocator;\nimport io.netty.channel.*;\nimport io.netty.util.Attribute;\nimport io.netty.util.AttributeKey;\nimport io.netty.util.AttributeMap;\nimport io.netty.util.DefaultAttributeMap;\n\nimport java.net.SocketAddress;\n\npublic class ChannelFake implements Channel\n{\n\tprivate final AttributeMap attributes = new DefaultAttributeMap();\n\n\t@Override\n\tpublic ChannelId id()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic EventLoop eventLoop()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic Channel parent()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelConfig config()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic boolean isOpen()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic boolean isRegistered()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic boolean isActive()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic ChannelMetadata metadata()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic SocketAddress localAddress()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic SocketAddress remoteAddress()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture closeFuture()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic boolean isWritable()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic long bytesBeforeUnwritable()\n\t{\n\t\treturn 0;\n\t}\n\n\t@Override\n\tpublic long bytesBeforeWritable()\n\t{\n\t\treturn 0;\n\t}\n\n\t@Override\n\tpublic Unsafe unsafe()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelPipeline pipeline()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ByteBufAllocator alloc()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture bind(SocketAddress socketAddress)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture disconnect()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture close()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture deregister()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture bind(SocketAddress socketAddress, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture disconnect(ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture close(ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture deregister(ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic Channel read()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture write(Object o)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture write(Object o, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic Channel flush()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture writeAndFlush(Object o, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture writeAndFlush(Object o)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelPromise newPromise()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelProgressivePromise newProgressivePromise()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture newSucceededFuture()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture newFailedFuture(Throwable throwable)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelPromise voidPromise()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic <T> Attribute<T> attr(AttributeKey<T> attributeKey)\n\t{\n\t\treturn attributes.attr(attributeKey);\n\t}\n\n\t@Override\n\tpublic <T> boolean hasAttr(AttributeKey<T> attributeKey)\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic int compareTo(Channel o)\n\t{\n\t\treturn 0;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/ChannelHandlerContextFake.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.buffer.ByteBufAllocator;\nimport io.netty.channel.*;\nimport io.netty.util.Attribute;\nimport io.netty.util.AttributeKey;\nimport io.netty.util.concurrent.EventExecutor;\n\nimport java.net.SocketAddress;\n\npublic class ChannelHandlerContextFake implements ChannelHandlerContext\n{\n\tprivate final Channel channel = new ChannelFake();\n\n\t@Override\n\tpublic Channel channel()\n\t{\n\t\treturn channel;\n\t}\n\n\t@Override\n\tpublic EventExecutor executor()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic String name()\n\t{\n\t\treturn \"\";\n\t}\n\n\t@Override\n\tpublic ChannelHandler handler()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic boolean isRemoved()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelRegistered()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelUnregistered()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelActive()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelInactive()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireExceptionCaught(Throwable throwable)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireUserEventTriggered(Object o)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelRead(Object o)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelReadComplete()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext fireChannelWritabilityChanged()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture bind(SocketAddress socketAddress)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture disconnect()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture close()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture deregister()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture bind(SocketAddress socketAddress, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture disconnect(ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture close(ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture deregister(ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext read()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture write(Object o)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture write(Object o, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelHandlerContext flush()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture writeAndFlush(Object o, ChannelPromise channelPromise)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture writeAndFlush(Object o)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelPromise newPromise()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelProgressivePromise newProgressivePromise()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture newSucceededFuture()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelFuture newFailedFuture(Throwable throwable)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelPromise voidPromise()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ChannelPipeline pipeline()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic ByteBufAllocator alloc()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic <T> Attribute<T> attr(AttributeKey<T> attributeKey)\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic <T> boolean hasAttr(AttributeKey<T> attributeKey)\n\t{\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/PacketDecoderPipelineTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.buffer.Unpooled;\nimport io.netty.channel.embedded.EmbeddedChannel;\nimport io.netty.handler.codec.DecoderException;\nimport io.netty.handler.codec.TooLongFrameException;\nimport io.netty.util.ReferenceCountUtil;\nimport io.xeres.app.net.peer.packet.MultiPacketBuilder;\nimport io.xeres.app.net.peer.packet.SimplePacketBuilder;\nimport io.xeres.app.net.peer.pipeline.ItemDecoder;\nimport io.xeres.app.net.peer.pipeline.PacketDecoder;\nimport io.xeres.app.xrs.item.RawItem;\nimport org.bouncycastle.crypto.Digest;\nimport org.bouncycastle.crypto.digests.SHA256Digest;\nimport org.junit.jupiter.api.Test;\n\nimport java.net.ProtocolException;\nimport java.util.concurrent.ThreadLocalRandom;\n\nimport static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_END;\nimport static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_START;\nimport static io.xeres.app.net.peer.packet.Packet.OPTIMAL_PACKET_SIZE;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PacketDecoderPipelineTest extends AbstractPipelineTest\n{\n\t@Test\n\tvoid NewPacket_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder());\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\t\tByteBuf inBuf = channel.readInbound();\n\t\tassertArrayEquals(inPacket, getByteBufAsArray(inBuf));\n\n\t\tReferenceCountUtil.release(inBuf);\n\t}\n\n\t@Test\n\tvoid NewPacket_ZeroSize()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\t\tRawItem rawItem = channel.readInbound();\n\t\tassertNotNull(rawItem);\n\n\t\tReferenceCountUtil.release(rawItem);\n\t}\n\n\t@Test\n\tvoid OldPacket_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder());\n\n\t\tvar inPacket = SimplePacketBuilder.builder()\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\t\tByteBuf inBuf = channel.readInbound();\n\t\tassertArrayEquals(inPacket, getByteBufAsArray(inBuf));\n\n\t\tReferenceCountUtil.release(inBuf);\n\t}\n\n\t@Test\n\tvoid OldPacket_TooSmall()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = SimplePacketBuilder.builder()\n\t\t\t\t.setHeaderSize(6)\n\t\t\t\t.build();\n\n\t\tvar message = Unpooled.wrappedBuffer(inPacket);\n\n\t\tassertThatThrownBy(() -> channel.writeInbound(message))\n\t\t\t\t.isInstanceOf(DecoderException.class)\n\t\t\t\t.hasCauseInstanceOf(ProtocolException.class)\n\t\t\t\t.hasMessageContaining(\"Packet size too small\");\n\t}\n\n\t/**\n\t * The old packets can be oversized, the new ones can't.\n\t */\n\t@Test\n\tvoid OldPacket_Oversized()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = SimplePacketBuilder.builder()\n\t\t\t\t.setHeaderSize(Integer.MAX_VALUE - 8)\n\t\t\t\t.build();\n\n\t\tvar message = Unpooled.wrappedBuffer(inPacket);\n\n\t\tassertThatThrownBy(() -> channel.writeInbound(message))\n\t\t\t\t.isInstanceOf(TooLongFrameException.class)\n\t\t\t\t.hasMessageStartingWith(\"Frame is too long\");\n\t}\n\n\t@Test\n\tvoid OldPacket_Empty_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = SimplePacketBuilder.builder()\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\t\tRawItem rawItem = channel.readInbound();\n\t\tassertNotNull(rawItem);\n\n\t\tReferenceCountUtil.release(rawItem);\n\t}\n\n\t@Test\n\tvoid NewPacket_Empty_DoubleStartPacket()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.setFlags(SLICE_FLAG_START)\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\n\t\tvar message = Unpooled.wrappedBuffer(inPacket);\n\n\t\tassertThatThrownBy(() -> channel.writeInbound(message)).isInstanceOf(DecoderException.class)\n\t\t\t\t.hasCauseInstanceOf(ProtocolException.class)\n\t\t\t\t.hasMessageFindingMatch(\"Start packet [0-9]* already received\");\n\t}\n\n\t@Test\n\tvoid NewPacket_Empty_MiddlePacketWithoutStartPacket()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.setFlags(0)\n\t\t\t\t.build();\n\n\t\tvar message = Unpooled.wrappedBuffer(inPacket);\n\n\t\tassertThatThrownBy(() -> channel.writeInbound(message)).isInstanceOf(DecoderException.class)\n\t\t\t\t.hasCauseInstanceOf(ProtocolException.class)\n\t\t\t\t.hasMessageFindingMatch(\"Middle packet [0-9]* received without corresponding start packet\");\n\t}\n\n\t@Test\n\tvoid NewPacket_Empty_EndPacketWithoutStartPacket()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.setFlags(SLICE_FLAG_END)\n\t\t\t\t.build();\n\n\t\tvar message = Unpooled.wrappedBuffer(inPacket);\n\n\t\tassertThatThrownBy(() -> channel.writeInbound(message)).isInstanceOf(DecoderException.class)\n\t\t\t\t.hasCauseInstanceOf(ProtocolException.class)\n\t\t\t\t.hasMessageFindingMatch(\"End packet [0-9]* received without corresponding start packet\");\n\t}\n\n\t@Test\n\tvoid NewPacket_Empty_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\t\tRawItem rawItem = channel.readInbound();\n\t\tassertNotNull(rawItem);\n\t\tassertEquals(0, rawItem.getBuffer().writerIndex());\n\t\tassertFalse(channel.finish());\n\n\t\tReferenceCountUtil.release(rawItem);\n\t}\n\n\t@Test\n\tvoid NewPacket_Slicing_SizesWithHeaders_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar inPacket1 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(SLICE_FLAG_START)\n\t\t\t\t.setData(new byte[OPTIMAL_PACKET_SIZE])\n\t\t\t\t.build();\n\n\t\tvar inPacket2 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(0)\n\t\t\t\t.setData(new byte[200])\n\t\t\t\t.build();\n\n\t\tvar inPacket3 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(SLICE_FLAG_END)\n\t\t\t\t.setData(new byte[100])\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket1));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket2));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket3));\n\n\t\tRawItem rawItem = channel.readInbound();\n\t\tassertNotNull(rawItem);\n\t\tassertEquals(OPTIMAL_PACKET_SIZE + 200 + 100, rawItem.getBuffer().writerIndex());\n\t\tassertFalse(channel.finish());\n\n\t\tReferenceCountUtil.release(rawItem);\n\t}\n\n\t/**\n\t * Creates 3 sliced buffers and tests if they're reassembled properly.\n\t */\n\t@Test\n\tvoid NewPacket_Slicing_DataIntegrity_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar data1 = new byte[OPTIMAL_PACKET_SIZE];\n\t\tvar data2 = new byte[200];\n\t\tvar data3 = new byte[100];\n\n\t\tThreadLocalRandom.current().nextBytes(data1);\n\t\tThreadLocalRandom.current().nextBytes(data2);\n\t\tThreadLocalRandom.current().nextBytes(data3);\n\n\t\tvar hashIn = computeHash(data1, data2, data3);\n\n\t\tvar inPacket1 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(SLICE_FLAG_START)\n\t\t\t\t.setData(data1)\n\t\t\t\t.build();\n\n\t\tvar inPacket2 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(0)\n\t\t\t\t.setData(data2)\n\t\t\t\t.build();\n\n\t\tvar inPacket3 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(SLICE_FLAG_END)\n\t\t\t\t.setData(data3)\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket1));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket2));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket3));\n\n\t\tRawItem rawItem = channel.readInbound();\n\t\tassertNotNull(rawItem);\n\t\tassertEquals(data1.length + data2.length + data3.length, rawItem.getBuffer().writerIndex());\n\t\tassertFalse(channel.finish());\n\t\tassertArrayEquals(hashIn, computeHash(getByteBufAsArray(rawItem.getBuffer())));\n\n\t\tReferenceCountUtil.release(rawItem);\n\t}\n\n\t@Test\n\tvoid NewPacket_Slicing_DataIntegrity_Intermixed_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar dataA1 = new byte[100];\n\t\tvar dataA2 = new byte[150];\n\t\tvar dataA3 = new byte[200];\n\n\t\tvar dataB1 = new byte[300];\n\t\tvar dataB2 = new byte[200];\n\t\tvar dataB3 = new byte[100];\n\n\t\tThreadLocalRandom.current().nextBytes(dataA1);\n\t\tThreadLocalRandom.current().nextBytes(dataA2);\n\t\tThreadLocalRandom.current().nextBytes(dataA3);\n\n\t\tThreadLocalRandom.current().nextBytes(dataB1);\n\t\tThreadLocalRandom.current().nextBytes(dataB2);\n\t\tThreadLocalRandom.current().nextBytes(dataB3);\n\n\t\tvar hashInA = computeHash(dataA1, dataA2, dataA3);\n\t\tvar hashInB = computeHash(dataB1, dataB2, dataB3);\n\n\t\tvar inPacketA1 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(SLICE_FLAG_START)\n\t\t\t\t.setData(dataA1)\n\t\t\t\t.build();\n\n\t\tvar inPacketA2 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(0)\n\t\t\t\t.setData(dataA2)\n\t\t\t\t.build();\n\n\t\tvar inPacketA3 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(1)\n\t\t\t\t.setFlags(SLICE_FLAG_END)\n\t\t\t\t.setData(dataA3)\n\t\t\t\t.build();\n\n\t\tvar inPacketB1 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(2)\n\t\t\t\t.setFlags(SLICE_FLAG_START)\n\t\t\t\t.setData(dataB1)\n\t\t\t\t.build();\n\n\t\tvar inPacketB2 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(2)\n\t\t\t\t.setFlags(0)\n\t\t\t\t.setData(dataB2)\n\t\t\t\t.build();\n\n\t\tvar inPacketB3 = MultiPacketBuilder.builder()\n\t\t\t\t.setPacketId(2)\n\t\t\t\t.setFlags(SLICE_FLAG_END)\n\t\t\t\t.setData(dataB3)\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacketA1));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacketB1));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacketA2));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacketB2));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacketA3));\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacketB3));\n\n\t\tRawItem rawItemA = channel.readInbound();\n\t\tRawItem rawItemB = channel.readInbound();\n\t\tassertNotNull(rawItemA);\n\t\tassertNotNull(rawItemB);\n\t\tassertEquals(dataA1.length + dataA2.length + dataA3.length, rawItemA.getBuffer().writerIndex());\n\t\tassertEquals(dataB1.length + dataB2.length + dataB3.length, rawItemB.getBuffer().writerIndex());\n\t\tassertFalse(channel.finish());\n\t\tassertArrayEquals(hashInA, computeHash(getByteBufAsArray(rawItemA.getBuffer())));\n\t\tassertArrayEquals(hashInB, computeHash(getByteBufAsArray(rawItemB.getBuffer())));\n\n\t\tReferenceCountUtil.release(rawItemA);\n\t\tReferenceCountUtil.release(rawItemB);\n\t}\n\n\tprivate byte[] computeHash(byte[]... buffers)\n\t{\n\t\tvar hash = new byte[32];\n\n\t\tDigest digest = new SHA256Digest();\n\t\tfor (var buf : buffers)\n\t\t{\n\t\t\tdigest.update(buf, 0, buf.length);\n\t\t}\n\t\tdigest.doFinal(hash, 0);\n\t\treturn hash;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/PacketEncoderPipelineTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.buffer.ByteBuf;\nimport io.netty.channel.embedded.EmbeddedChannel;\nimport io.netty.util.ReferenceCountUtil;\nimport io.xeres.app.net.peer.packet.Packet;\nimport io.xeres.app.net.peer.packet.SimplePacketBuilder;\nimport io.xeres.app.net.peer.pipeline.SimplePacketEncoder;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass PacketEncoderPipelineTest extends AbstractPipelineTest\n{\n\t@Test\n\tvoid RsOldPacketEncoder_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new SimplePacketEncoder());\n\n\t\tPacket outPacket = SimplePacketBuilder.builder().buildPacket();\n\n\t\tchannel.writeAndFlush(outPacket);\n\t\tByteBuf outBuf = channel.readOutbound();\n\t\tassertEquals(outPacket.getSize(), outBuf.writerIndex());\n\t\tReferenceCountUtil.release(outBuf);\n\t}\n\n\tpublic void RsNewPacketEncoder_OK()\n\t{\n//\t\tvar channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tPacket inPacket = PacketBuilder.builder().buildPacket();\n//\n//\t\tchannel.attr(PeerHandler.MULTI_PACKET).set(true);\n//\t\tchannel.writeAndFlush(inPacket);\n//\t\tByteBuf outBuf = channel.readOutbound();\n//\t\tassertEquals(inPacket.getSize(), outBuf.readableBytes());\n//\t\tReferenceCountUtil.release(outBuf);\n\t}\n\n\tpublic void RsNewPacketEncoder_OldPacket_OK()\n\t{\n//\t\tvar channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tPacket inPacket = PacketBuilder.builder()\n//\t\t\t\t.setData(new byte[]{1, 2, 3, 4})\n//\t\t\t\t.buildPacket();\n//\n//\t\tchannel.writeAndFlush(inPacket);\n//\t\tvar outPacket = new byte[4];\n//\t\tByteBuf outBuf = channel.readOutbound();\n//\t\toutBuf.readBytes(outPacket);\n//\t\t//assertArrayEquals(inPacket.getData(), outPacket);\n//\n//\t\tReferenceCountUtil.release(outBuf);\n\t}\n\n\tpublic void RsPacketEncoder_Small_OK()\n\t{\n//\t\tvar channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tPacket inPacket = PacketBuilder.builder()\n//\t\t\t\t.setData(new byte[]{1, 2, 3, 4})\n//\t\t\t\t.buildPacket();\n//\n//\t\tchannel.attr(PeerHandler.MULTI_PACKET).set(true);\n//\t\tchannel.writeAndFlush(inPacket);\n//\t\tchannel.runPendingTasks();\n//\t\tvar outPacket = new byte[4];\n//\t\tskipHeader(channel);\n//\t\tByteBuf outBuf = channel.readOutbound();\n//\t\toutBuf.readBytes(outPacket);\n//\t\t//assertArrayEquals(inPacket.getData(), outPacket);\n//\n//\t\tReferenceCountUtil.release(outBuf);\n\t}\n\n\tpublic void RsPacketEncoder_Optimal_OK()\n\t{\n//\t\tEmbeddedChannel channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tPacket inPacket = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(Packet.OPTIMAL_PACKET_SIZE)\n//\t\t\t\t.buildPacket();\n//\n//\t\tchannel.attr(PeerHandler.MULTI_PACKET).set(true);\n//\t\tchannel.writeAndFlush(inPacket);\n//\t\tchannel.runPendingTasks();\n//\t\tvar outPacket = new byte[Packet.OPTIMAL_PACKET_SIZE];\n//\t\tskipHeader(channel);\n//\t\tByteBuf outBuf = channel.readOutbound();\n//\t\toutBuf.readBytes(outPacket);\n//\t\t//assertArrayEquals(inPacket.getData(), outPacket);\n//\n//\t\tReferenceCountUtil.release(outBuf);\n\t}\n\n\tpublic void RsPacketEncoder_Big_OK()\n\t{\n//\t\tvar channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tbyte[] inPacket = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(Packet.OPTIMAL_PACKET_SIZE * 3 + 200)\n//\t\t\t\t.build();\n//\n//\t\tchannel.attr(PeerHandler.MULTI_PACKET).set(true);\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket));\n//\t\tByteBuf outBuf = channel.readOutbound();\n//\t\tbyte[] outPacket = outBuf.array();\n//\t\tassertArrayEquals(inPacket, outPacket);\n//\n//\t\tReferenceCountUtil.release(outBuf);\n\t}\n\n\tpublic void RsPacketEncoder_Multiple_OK()\n\t{\n//\t\tvar channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tbyte[] inPacket1 = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(Packet.OPTIMAL_PACKET_SIZE * 3 + 200)\n//\t\t\t\t.build();\n//\n//\t\tbyte[] inPacket2 = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(6)\n//\t\t\t\t.build();\n//\n//\t\tbyte[] inPacket3 = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(Packet.OPTIMAL_PACKET_SIZE * 2)\n//\t\t\t\t.build();\n//\n//\t\tchannel.attr(PeerHandler.MULTI_PACKET).set(true);\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket1));\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket2));\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket3));\n//\t\tByteBuf outBuf1 = channel.readOutbound();\n//\t\tbyte[] outPacket1 = outBuf1.array();\n//\t\tByteBuf outBuf2 = channel.readOutbound();\n//\t\tbyte[] outPacket2 = outBuf1.array();\n//\t\tByteBuf outBuf3 = channel.readOutbound();\n//\t\tbyte[] outPacket3 = outBuf1.array();\n//\n//\t\tassertArrayEquals(inPacket1, outPacket1);\n//\t\tassertArrayEquals(inPacket2, outPacket2);\n//\t\tassertArrayEquals(inPacket3, outPacket3);\n//\n//\t\tReferenceCountUtil.release(outBuf1);\n//\t\tReferenceCountUtil.release(outBuf2);\n//\t\tReferenceCountUtil.release(outBuf3);\n\t}\n\n\tpublic void RsPacketEncoder_Multiple_Priority_OK()\n\t{\n//\t\tvar channel = new EmbeddedChannel(new MultiPacketEncoder());\n//\n//\t\tbyte[] inPacket1 = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(Packet.OPTIMAL_PACKET_SIZE * 3 + 200)\n//\t\t\t\t.build();\n//\n//\t\tbyte[] inPacket2 = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(6)\n//\t\t\t\t.setPriority(9)\n//\t\t\t\t.build();\n//\n//\t\tbyte[] inPacket3 = PacketBuilder.builder()\n//\t\t\t\t.setRandomData(Packet.OPTIMAL_PACKET_SIZE * 2)\n//\t\t\t\t.build();\n//\n//\t\tchannel.attr(PeerHandler.MULTI_PACKET).set(true);\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket1));\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket2));\n//\t\tchannel.writeAndFlush(Unpooled.wrappedBuffer(inPacket3));\n//\n//\t\tByteBuf outBuf1 = channel.readOutbound();\n//\t\tbyte[] outPacket1 = outBuf1.array();\n//\t\tByteBuf outBuf2 = channel.readOutbound();\n//\t\tbyte[] outPacket2 = outBuf1.array();\n//\t\tByteBuf outBuf3 = channel.readOutbound();\n//\t\tbyte[] outPacket3 = outBuf1.array();\n//\n//\t\tassertArrayEquals(inPacket1, outPacket1);\n//\t\tassertArrayEquals(inPacket2, outPacket2);\n//\t\tassertArrayEquals(inPacket3, outPacket3);\n//\n//\t\tReferenceCountUtil.release(outBuf1);\n//\t\tReferenceCountUtil.release(outBuf2);\n//\t\tReferenceCountUtil.release(outBuf3);\n\t}\n\n\tprivate void skipHeader(EmbeddedChannel channel)\n\t{\n\t\tByteBuf byteBuf = channel.readOutbound();\n\t\tbyteBuf.skipBytes(8);\n\n\t\tReferenceCountUtil.release(byteBuf);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/PeerAttributeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass PeerAttributeTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(PeerAttribute.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/PeerConnectionFakes.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.xeres.app.database.model.location.LocationFakes;\n\npublic final class PeerConnectionFakes\n{\n\tprivate PeerConnectionFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static PeerConnection createPeerConnection()\n\t{\n\t\treturn new PeerConnection(LocationFakes.createLocation(), null);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/PeerConnectionManagerTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.service.notification.availability.AvailabilityNotificationService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.context.ApplicationEventPublisher;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@ExtendWith(MockitoExtension.class)\nclass PeerConnectionManagerTest\n{\n\t@Mock\n\tprivate StatusNotificationService statusNotificationService;\n\n\t@Mock\n\tprivate AvailabilityNotificationService availabilityNotificationService;\n\n\t@Mock\n\tApplicationEventPublisher publisher;\n\n\t@InjectMocks\n\tprivate PeerConnectionManager peerConnectionManager;\n\n\t@Test\n\tvoid addAndRemovePeers()\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\n\t\tvar peerConnection = peerConnectionManager.addPeer(location, new ChannelHandlerContextFake());\n\t\tassertNotNull(peerConnection);\n\t\tassertEquals(1, peerConnectionManager.getNumberOfPeers());\n\t\tassertEquals(peerConnection, peerConnectionManager.getPeerByLocation(location.getId()));\n\t\tassertEquals(peerConnection, peerConnectionManager.getRandomPeer());\n\t\tpeerConnectionManager.removePeer(location);\n\t\tassertEquals(0, peerConnectionManager.getNumberOfPeers());\n\t}\n\n\t@Test\n\tvoid addPeerAlreadyHere()\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\n\t\tpeerConnectionManager.addPeer(location, new ChannelHandlerContextFake());\n\t\tassertThrows(IllegalStateException.class, () -> peerConnectionManager.addPeer(location, new ChannelHandlerContextFake()));\n\t}\n\n\t@Test\n\tvoid removePeerNotHere()\n\t{\n\t\tvar location = LocationFakes.createLocation();\n\n\t\tassertThrows(IllegalStateException.class, () -> peerConnectionManager.removePeer(location));\n\t}\n\n\t@Test\n\tvoid getRandomPeer()\n\t{\n\t\tvar location1 = LocationFakes.createLocation();\n\t\tvar location2 = LocationFakes.createLocation();\n\n\t\tpeerConnectionManager.addPeer(location1, new ChannelHandlerContextFake());\n\t\tpeerConnectionManager.addPeer(location2, new ChannelHandlerContextFake());\n\t\tassertNotNull(peerConnectionManager.getRandomPeer());\n\t}\n\n\t@Test\n\tvoid getRandomPeer_Empty()\n\t{\n\t\tassertNull(peerConnectionManager.getRandomPeer());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/RawItemDecoderPipelineTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer;\n\nimport io.netty.buffer.ByteBufAllocator;\nimport io.netty.buffer.Unpooled;\nimport io.netty.channel.embedded.EmbeddedChannel;\nimport io.netty.util.ReferenceCountUtil;\nimport io.xeres.app.net.peer.packet.MultiPacketBuilder;\nimport io.xeres.app.net.peer.packet.SimplePacketBuilder;\nimport io.xeres.app.net.peer.pipeline.ItemDecoder;\nimport io.xeres.app.net.peer.pipeline.PacketDecoder;\nimport io.xeres.app.xrs.item.RawItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.sliceprobe.item.SliceProbeItem;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.EnumSet;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nclass RawItemDecoderPipelineTest extends AbstractPipelineTest\n{\n\t@Test\n\tvoid NewPacket_Decode_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar item = new SliceProbeItem();\n\t\titem.setOutgoing(ByteBufAllocator.DEFAULT, null);\n\t\tvar itemIn = item.serializeItem(EnumSet.noneOf(SerializationFlags.class));\n\n\t\tvar inPacket = MultiPacketBuilder.builder()\n\t\t\t\t.setRawItem(itemIn)\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\n\t\tRawItem rawItemOut = channel.readInbound();\n\t\tassertNotNull(rawItemOut);\n\t\tassertArrayEquals(getByteBufAsArray(itemIn.getBuffer()), getByteBufAsArray(rawItemOut.getBuffer()));\n\n\t\tReferenceCountUtil.release(rawItemOut);\n\t}\n\n\t@Test\n\tvoid OldPacket_Decode_Success()\n\t{\n\t\tvar channel = new EmbeddedChannel(new PacketDecoder(), new ItemDecoder());\n\n\t\tvar item = new SliceProbeItem();\n\t\titem.setOutgoing(ByteBufAllocator.DEFAULT, null);\n\t\tvar itemIn = item.serializeItem(EnumSet.noneOf(SerializationFlags.class));\n\n\t\tvar inPacket = SimplePacketBuilder.builder()\n\t\t\t\t.setRawItem(itemIn)\n\t\t\t\t.build();\n\n\t\tchannel.writeInbound(Unpooled.wrappedBuffer(inPacket));\n\n\t\tRawItem rawItemOut = channel.readInbound();\n\t\tassertNotNull(rawItemOut);\n\t\tassertArrayEquals(getByteBufAsArray(itemIn.getBuffer()), getByteBufAsArray(rawItemOut.getBuffer()));\n\n\t\tReferenceCountUtil.release(rawItemOut);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/packet/MultiPacketBuilder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.packet;\n\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.xrs.item.RawItem;\n\nimport static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_END;\nimport static io.xeres.app.net.peer.packet.MultiPacket.SLICE_FLAG_START;\nimport static io.xeres.app.net.peer.packet.Packet.SLICE_PROTOCOL_VERSION_ID_01;\n\npublic final class MultiPacketBuilder\n{\n\tprivate MultiPacketBuilder()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static final class Builder\n\t{\n\t\tprivate int flags = SLICE_FLAG_START | SLICE_FLAG_END; // XXX: make that settable\n\t\tprivate int packetId;\n\t\tprivate byte[] data = new byte[0];\n\t\tprivate RawItem rawItem;\n\n\t\tprivate Builder()\n\t\t{\n\t\t}\n\n\t\tpublic Builder setFlags(int flags)\n\t\t{\n\t\t\tthis.flags = flags;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setPacketId(int packetId)\n\t\t{\n\t\t\tthis.packetId = packetId;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setData(byte[] data)\n\t\t{\n\t\t\tthis.data = data;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setRawItem(RawItem rawItem)\n\t\t{\n\t\t\tthis.rawItem = rawItem;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic MultiPacket buildPacket()\n\t\t{\n\t\t\tvar buf = Unpooled.buffer();\n\n\t\t\tbuf.writeByte(SLICE_PROTOCOL_VERSION_ID_01);\n\t\t\tbuf.writeByte(flags);\n\t\t\tbuf.writeInt(packetId);\n\n\t\t\tif (rawItem != null)\n\t\t\t{\n\t\t\t\tvar itemBuf = rawItem.getBuffer();\n\t\t\t\tbuf.writeShort(itemBuf.writerIndex());\n\t\t\t\tbuf.writeBytes(rawItem.getBuffer());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tbuf.writeShort(data.length);\n\t\t\t\tbuf.writeBytes(data);\n\t\t\t}\n\t\t\treturn new MultiPacket(buf);\n\t\t}\n\n\t\tpublic byte[] build() // XXX: is it what we want or do we just need the content?\n\t\t{\n\t\t\tvar buf = buildPacket().getBuffer();\n\t\t\tvar bytes = new byte[buf.writerIndex()];\n\t\t\tbuf.readBytes(bytes);\n\t\t\treturn bytes;\n\t\t}\n\t}\n\n\tpublic static Builder builder()\n\t{\n\t\treturn new Builder();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/packet/PacketTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.packet;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass PacketTest\n{\n\t@Test\n\tvoid IsSimple_Success()\n\t{\n\t\tvar packet = SimplePacketBuilder.builder().buildPacket();\n\t\tassertFalse(packet.isMulti());\n\t}\n\n\t@Test\n\tvoid IsMulti_Success()\n\t{\n\t\tvar packet = MultiPacketBuilder.builder().buildPacket();\n\t\tassertTrue(packet.isMulti());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/packet/SimplePacketBuilder.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.packet;\n\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.xrs.item.RawItem;\n\nimport static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE;\n\npublic final class SimplePacketBuilder\n{\n\tprivate SimplePacketBuilder()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static final class Builder\n\t{\n\t\tprivate int headerSize = HEADER_SIZE;\n\t\tprivate int version;\n\t\tprivate int service;\n\t\tprivate int subPacket;\n\t\tprivate byte[] data = new byte[0];\n\t\tprivate RawItem rawItem;\n\n\t\tprivate Builder()\n\t\t{\n\t\t}\n\n\t\tpublic Builder setVersion(int version)\n\t\t{\n\t\t\tthis.version = version;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setService(int service)\n\t\t{\n\t\t\tthis.service = service;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setSubPacket(int subPacket)\n\t\t{\n\t\t\tthis.subPacket = subPacket;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setData(byte[] data)\n\t\t{\n\t\t\tthis.data = data;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setRawItem(RawItem rawItem)\n\t\t{\n\t\t\tthis.rawItem = rawItem;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic Builder setHeaderSize(int size)\n\t\t{\n\t\t\theaderSize = size;\n\t\t\treturn this;\n\t\t}\n\n\t\tpublic SimplePacket buildPacket()\n\t\t{\n\t\t\tvar buf = Unpooled.buffer();\n\n\t\t\tif (rawItem != null)\n\t\t\t{\n\t\t\t\tbuf.writeBytes(rawItem.getBuffer());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tbuf.writeByte(version);\n\t\t\t\tbuf.writeShort(service);\n\t\t\t\tbuf.writeByte(subPacket);\n\t\t\t\tbuf.writeInt(headerSize + data.length);\n\t\t\t\tbuf.writeBytes(data);\n\t\t\t}\n\t\t\treturn new SimplePacket(buf);\n\t\t}\n\n\t\tpublic byte[] build()\n\t\t{\n\t\t\tvar buf = buildPacket().getBuffer();\n\t\t\tvar bytes = new byte[buf.writerIndex()];\n\t\t\tbuf.readBytes(bytes);\n\t\t\treturn bytes;\n\t\t}\n\t}\n\n\tpublic static Builder builder()\n\t{\n\t\treturn new Builder();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/peer/ssl/SSLTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.peer.ssl;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.crypto.rsid.RSSerialVersion;\nimport io.xeres.app.crypto.x509.X509;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.testutils.TestUtils;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport javax.net.ssl.SSLException;\nimport java.io.IOException;\nimport java.security.KeyPair;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.Security;\nimport java.security.cert.CertificateException;\nimport java.security.cert.X509Certificate;\nimport java.security.spec.InvalidKeySpecException;\nimport java.util.Date;\nimport java.util.Optional;\n\nimport static io.xeres.app.net.peer.ConnectionType.*;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass SSLTest\n{\n\tprivate static PGPSecretKey pgpKey;\n\tprivate static KeyPair rsaKey;\n\tprivate static Profile profile;\n\tprivate static X509Certificate certificate;\n\n\t@Mock\n\tprivate ProfileService profileService;\n\n\t@Mock\n\tprivate LocationService locationService;\n\n\t@BeforeAll\n\tstatic void setup() throws PGPException, IOException, CertificateException\n\t{\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\n\t\tpgpKey = PGP.generateSecretKey(\"foo\", \"\", 512);\n\t\trsaKey = RSA.generateKeys(512);\n\t\tprofile = ProfileFakes.createProfile(\"foo\", pgpKey.getKeyID(), pgpKey.getPublicKey().getFingerprint(), pgpKey.getPublicKey().getEncoded());\n\t\tprofile.setAccepted(true);\n\n\t\tcertificate = X509.generateCertificate(pgpKey, rsaKey.getPublic(), \"CN=\" + Id.toString(profile.getPgpIdentifier()), \"CN=-\", new Date(0), new Date(0), RSSerialVersion.V07_0001.serialNumber());\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(SSL.class);\n\t}\n\n\t@Test\n\tvoid CreateClientContext_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException\n\t{\n\t\tvar sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, TCP_OUTGOING);\n\n\t\tassertNotNull(sslContext);\n\t\tassertTrue(sslContext.isClient());\n\t}\n\n\t@Test\n\tvoid CreateServerContext_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException\n\t{\n\t\tvar sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, TCP_INCOMING);\n\n\t\tassertNotNull(sslContext);\n\t\tassertTrue(sslContext.isServer());\n\t}\n\n\t@Test\n\tvoid CreateServerContext_Tor_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException\n\t{\n\t\tvar sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, TOR_OUTGOING);\n\n\t\tassertNotNull(sslContext);\n\t\tassertTrue(sslContext.isClient());\n\t}\n\n\t@Test\n\tvoid CreateServerContext_I2P_Success() throws InvalidKeySpecException, NoSuchAlgorithmException, SSLException\n\t{\n\t\tvar sslContext = SSL.createSslContext(rsaKey.getPrivate().getEncoded(), certificate, I2P_OUTGOING);\n\n\t\tassertNotNull(sslContext);\n\t\tassertTrue(sslContext.isClient());\n\t}\n\n\t@Test\n\tvoid CheckPeerCertificate_Success() throws CertificateException\n\t{\n\t\tvar location = LocationFakes.createLocation(\"bar\", profile);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.of(location));\n\n\t\tvar result = SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{certificate});\n\n\t\tassertEquals(result, location);\n\t\tverify(locationService).findLocationByLocationIdentifier(any(LocationIdentifier.class));\n\t}\n\n\t@Test\n\tvoid CheckPeerCertificate_EmptyCertificate_Failure()\n\t{\n\t\tassertThatThrownBy(() -> SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{}))\n\t\t\t\t.isInstanceOf(CertificateException.class)\n\t\t\t\t.hasMessage(\"Empty certificate\");\n\n\t\tverify(locationService, never()).findLocationByLocationIdentifier(any(LocationIdentifier.class));\n\t}\n\n\t@Test\n\tvoid CheckPeerCertificate_AlreadyConnected_Failure()\n\t{\n\t\tvar location = LocationFakes.createLocation(\"bar\", profile);\n\t\tlocation.setConnected(true);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.of(location));\n\n\t\tassertThatThrownBy(() -> SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{certificate}))\n\t\t\t\t.isInstanceOf(CertificateException.class)\n\t\t\t\t.hasMessage(\"Already connected\");\n\n\t\tverify(locationService).findLocationByLocationIdentifier(any(LocationIdentifier.class));\n\t}\n\n\t@Test\n\tvoid CheckPeerCertificate_WrongCertificate_Failure() throws CertificateException, IOException, PGPException\n\t{\n\t\tvar wrongPgpKey = PGP.generateSecretKey(\"notFoo\", \"\", 512);\n\t\tvar wrongCertificate = X509.generateCertificate(wrongPgpKey, rsaKey.getPublic(), \"CN=me\", \"CN=foobar\", new Date(0), new Date(0), RSSerialVersion.V07_0001.serialNumber());\n\t\tvar location = LocationFakes.createLocation(\"bar\", profile);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.of(location));\n\n\t\tassertThatThrownBy(() -> SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{wrongCertificate}))\n\t\t\t\t.isInstanceOf(CertificateException.class)\n\t\t\t\t.hasMessageContaining(\"Wrong signature\");\n\n\t\tverify(locationService).findLocationByLocationIdentifier(any(LocationIdentifier.class));\n\t}\n\n\t@Test\n\tvoid CheckPeerCertificate_NoLocationButProfile_Success() throws CertificateException\n\t{\n\t\twhen(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.empty());\n\t\twhen(profileService.findProfileByPgpIdentifier(profile.getPgpIdentifier())).thenReturn(Optional.of(profile));\n\n\t\tvar newLocation = SSL.checkPeerCertificate(profileService, locationService, new X509Certificate[]{certificate});\n\n\t\tassertNotNull(newLocation);\n\t\tassertNull(newLocation.getName());\n\t\tassertEquals(\"[Unknown]\", newLocation.getSafeName());\n\t\tassertEquals(newLocation.getProfile(), profile);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/protocol/PeerAddressTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.protocol;\n\nimport io.xeres.app.net.protocol.PeerAddress.Type;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.NullAndEmptySource;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport java.net.InetSocketAddress;\nimport java.util.NoSuchElementException;\nimport java.util.Optional;\n\nimport static io.xeres.app.net.protocol.PeerAddress.Type.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PeerAddressTest\n{\n\t/**\n\t * Builds a PeerAddress from a string like \"85.123.33.21:21232\"\n\t */\n\t@Test\n\tvoid FromIpAndPort_Success()\n\t{\n\t\tvar ipAndPort = \"85.123.33.21:21232\";\n\t\tvar peerAddress = PeerAddress.fromIpAndPort(ipAndPort);\n\n\t\tassertEquals(Optional.of(ipAndPort), peerAddress.getAddress());\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isExternal());\n\t\tassertFalse(peerAddress.isHidden());\n\t\tassertFalse(peerAddress.isHostname());\n\t\tassertFalse(peerAddress.isLAN());\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"500.500.500.500:21232\", // overflow\n\t\t\t\"85.123.33:21232\", // octet missing\n\t\t\t\"85.123.33.01:21232\", // octet zero prefix\n\t\t\t\"85.123.33.a:21232\", // octet not a number\n\t\t\t\"85.123.33.1:a\", // port not a number\n\t\t\t\"85.123.33.1:\", // separator but missing port\n\t\t\t\":2323\", // separator but missing IP\n\t\t\t\"85.1:21232\", // valid IP but confusing\n\t\t\t\"85.123.33.0xa\", // valid IP but confusing\n\t\t\t\"85.65530:21232\", // valid IP but confusing\n\t\t\t\"283943283\", // valid IP but confusing\n\t\t\t\"2384902378237892\", // invalid IP (and confusing)\n\t\t\t\"85.123.33.21:0\", // low port\n\t\t\t\"85.123.33.21:65537\", // illegal port\n\t\t\t\"127.0.0.1:21232\", // localhost\n\t\t\t\"0.0.0.0:21232\", // \"network\" address\n\t\t\t\"255.255.255.255:21232\", // \"broadcast\" address\n\t\t\t\"0.1.1.1:21232\", // non routable\n\t})\n\tvoid FromIpAndPort_Fail(String source)\n\t{\n\t\tvar peerAddress = PeerAddress.fromIpAndPort(source);\n\n\t\tassertFalse(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isInvalid());\n\t\tassertTrue(peerAddress.getAddress().isEmpty());\n\t\tassertTrue(peerAddress.getAddressAsBytes().isEmpty());\n\t\tassertFalse(peerAddress.isHostname());\n\t\tassertFalse(peerAddress.isHidden());\n\t\tassertNull(peerAddress.getSocketAddress());\n\t\tassertEquals(INVALID, peerAddress.getType());\n\t\tassertThrows(NoSuchElementException.class, peerAddress::getUrl);\n\t}\n\n\t@Test\n\tvoid FromUrl_Success()\n\t{\n\t\tvar url = \"ipv4://194.28.22.1:2233\";\n\t\tvar peerAddress = PeerAddress.fromUrl(url);\n\n\t\tassertEquals(url, peerAddress.getUrl());\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertFalse(peerAddress.isHidden());\n\t\tassertFalse(peerAddress.isHostname());\n\t}\n\n\t@ParameterizedTest\n\t@NullAndEmptySource\n\t@ValueSource(strings = {\n\t\t\t\"ipv4://194.28.22.1\", // missing port\n\t\t\t\"ipv5://194.28.22.1:1234\", // bad protocol\n\t\t\t\"ipv666://23sd.2343.2487.asdk\" // nonsense\n\t})\n\tvoid FromUrl_Failure(String url)\n\t{\n\t\tvar peerAddress = PeerAddress.fromUrl(url);\n\n\t\tassertFalse(peerAddress.isValid());\n\t\tassertThrows(NoSuchElementException.class, peerAddress::getUrl);\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"194.28.22.1:1026\",\n\t\t\t\"1.0.0.1:1026\"\n\t})\n\tvoid FromAddress_Success(String source)\n\t{\n\t\tvar peerAddress = PeerAddress.fromAddress(source);\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isExternal());\n\t\tassertFalse(peerAddress.isLAN());\n\t}\n\n\t@Test\n\tvoid FromAddress_MissingPort_Failure()\n\t{\n\t\tvar peerAddress = PeerAddress.fromAddress(\"194.28.22.1\");\n\n\t\tassertFalse(peerAddress.isValid());\n\t}\n\n\t@Test\n\tvoid FromIpAndPort_NotPublicButPrivateLan_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromIpAndPort(\"192.168.1.5:21232\");\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isLAN());\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"1.1.1.255:21232\", // broadcast convention\n\t\t\t\"1.1.1.0:21232\" // network convention\n\t})\n\tvoid FromIpAndPort_ConventionButRoutable_Success(String source)\n\t{\n\t\tvar peerAddress = PeerAddress.fromIpAndPort(source);\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isExternal());\n\t\tassertFalse(peerAddress.isLAN());\n\t}\n\n\t/**\n\t * Tor v2 is not supported anymore\n\t */\n\t@Test\n\tvoid FromTor_v2_Failure()\n\t{\n\t\tvar peerAddress = PeerAddress.fromOnion(\"expyuzz4wqqyqhjn.onion\");\n\n\t\tassertFalse(peerAddress.isValid());\n\t}\n\n\t@Test\n\tvoid FromTor_v3_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromOnion(\"xpxduj55x2j27l2qytu2tcetykyfxbjbafin3x4i3ywddzphkbrd3jyd.onion:1234\");\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertEquals(Type.TOR, peerAddress.getType());\n\t}\n\n\t@Test\n\tvoid FromI2p_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromI2p(\"g6u4vqiuy6bdc3dbu6a7gmi3ip45sqwgtbgrr6uupqaaqfyztrka.b32.i2p:1234\");\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertEquals(Type.I2P, peerAddress.getType());\n\t}\n\n\t@Test\n\tvoid FromTor_WrongAddress_Failure()\n\t{\n\t\tvar peerAddress = PeerAddress.fromOnion(\"192.168.1.2:8080\");\n\n\t\tassertFalse(peerAddress.isValid());\n\t\tassertFalse(peerAddress.isHidden());\n\t}\n\n\t@Test\n\tvoid FromHidden_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromHidden(\"xpxduj55x2j27l2qytu2tcetykyfxbjbafin3x4i3ywddzphkbrd3jyd.onion:1234\");\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isHidden());\n\t}\n\n\t@Test\n\tvoid FromHidden_WrongAddress_Failure()\n\t{\n\t\tvar peerAddress = PeerAddress.fromHidden(\"192.168.1.2:8080\");\n\n\t\tassertFalse(peerAddress.isValid());\n\t\tassertFalse(peerAddress.isHidden());\n\t}\n\n\t@Test\n\tvoid FromHostname_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromHostname(\"foo.bar.com\");\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isHostname());\n\t\tassertInstanceOf(DomainNameSocketAddress.class, peerAddress.getSocketAddress());\n\t}\n\n\t@Test\n\tvoid FromHostName_Invalid()\n\t{\n\t\tvar peerAddress = PeerAddress.fromHostname(\"verylonghostnamethatismorethan63charsandislikelyinvalidandwillfailspectacularly.com\");\n\n\t\tassertFalse(peerAddress.isValid());\n\t}\n\n\t@Test\n\tvoid FromHostNameAndPort_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromHostname(\"foo.bar.com\", 8080);\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isHostname());\n\t\tassertInstanceOf(InetSocketAddress.class, peerAddress.getSocketAddress());\n\t\tassertEquals(\"foo.bar.com\", ((InetSocketAddress) peerAddress.getSocketAddress()).getHostString());\n\t\tassertEquals(8080, ((InetSocketAddress) peerAddress.getSocketAddress()).getPort());\n\t}\n\n\t@Test\n\tvoid FromSocketAddress_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromSocketAddress(InetSocketAddress.createUnresolved(\"foobar.com\", 1234));\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertFalse(peerAddress.isHostname());\n\t\tassertInstanceOf(InetSocketAddress.class, peerAddress.getSocketAddress());\n\t\tassertEquals(\"foobar.com\", ((InetSocketAddress) peerAddress.getSocketAddress()).getHostString());\n\t\tassertEquals(1234, ((InetSocketAddress) peerAddress.getSocketAddress()).getPort());\n\t}\n\n\t@Test\n\tvoid FromHostNameAndPortString_Success()\n\t{\n\t\tvar peerAddress = PeerAddress.fromHostnameAndPort(\"foo.bar.com:8080\");\n\n\t\tassertTrue(peerAddress.isValid());\n\t\tassertTrue(peerAddress.isHostname());\n\t\tassertInstanceOf(InetSocketAddress.class, peerAddress.getSocketAddress());\n\t\tassertEquals(\"foo.bar.com\", ((InetSocketAddress) peerAddress.getSocketAddress()).getHostString());\n\t\tassertEquals(8080, ((InetSocketAddress) peerAddress.getSocketAddress()).getPort());\n\t}\n\n\t@Test\n\tvoid Type_Enum_Order()\n\t{\n\t\tassertEquals(0, INVALID.ordinal());\n\t\tassertEquals(1, IPV4.ordinal());\n\t\tassertEquals(2, IPV6.ordinal());\n\t\tassertEquals(3, TOR.ordinal());\n\t\tassertEquals(4, HOSTNAME.ordinal());\n\t\tassertEquals(5, I2P.ordinal());\n\n\t\tassertEquals(6, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/protocol/i2p/I2pAddressTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.protocol.i2p;\n\nimport io.xeres.common.protocol.i2p.I2pAddress;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.common.protocol.i2p.I2pAddress.isValidAddress;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass I2pAddressTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(I2pAddress.class);\n\t}\n\n\t@Test\n\tvoid IsValidAddress_Success()\n\t{\n\t\tassertTrue(isValidAddress(\"g6u4vqiuy6bdc3dbu6a7gmi3ip45sqwgtbgrr6uupqaaqfyztrka.b32.i2p:1234\"));\n\t}\n\n\t@Test\n\tvoid IsValidAddress_Failure()\n\t{\n\t\tassertFalse(isValidAddress(\"foobar.b32.i2p:1234\"));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/protocol/tor/OnionAddressTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.protocol.tor;\n\nimport io.xeres.common.protocol.tor.OnionAddress;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.common.protocol.tor.OnionAddress.isValidAddress;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass OnionAddressTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(OnionAddress.class);\n\t}\n\n\t@Test\n\tvoid IsValidAddress_Success()\n\t{\n\t\tassertTrue(isValidAddress(\"answerszuvs3gg2l64e6hmnryudl5zgrmwm3vh65hzszdghblddvfiqd.onion:1234\"));\n\t}\n\n\t@Test\n\tvoid IsValidAddress_Failure()\n\t{\n\t\tassertFalse(isValidAddress(\"3g2upl4pq6kufc4m.onion:1234\"));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/upnp/ControlPointTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport io.xeres.testutils.FakeHttpServer;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.net.URI;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass ControlPointTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ControlPoint.class);\n\t}\n\n\t@Test\n\tvoid AddPortMapping_Success()\n\t{\n\t\tvar fakeHTTPServer = new FakeHttpServer(\"/control\", 200, null);\n\n\t\tvar added = ControlPoint.addPortMapping(\n\t\t\t\tURI.create(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/control\"),\n\t\t\t\t\"urn:schemas-upnp-org:service:WANIPConnection:1\",\n\t\t\t\t\"192.168.1.78\",\n\t\t\t\t2000,\n\t\t\t\t2000,\n\t\t\t\t3600,\n\t\t\t\tProtocol.TCP\n\t\t);\n\t\tassertTrue(added);\n\n\t\tfakeHTTPServer.shutdown();\n\t}\n\n\t@Test\n\tvoid RemovePortMapping_Success()\n\t{\n\t\tvar fakeHTTPServer = new FakeHttpServer(\"/control\", 200, null);\n\n\t\tvar removed = ControlPoint.removePortMapping(\n\t\t\t\tURI.create(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/control\"),\n\t\t\t\t\"urn:schemas-upnp-org:service:WANIPConnection:1\",\n\t\t\t\t2000,\n\t\t\t\tProtocol.TCP\n\t\t);\n\t\tassertTrue(removed);\n\n\t\tfakeHTTPServer.shutdown();\n\t}\n\n\t@Test\n\tvoid GetExternalIPAddress_Success()\n\t{\n\t\tvar responseBody = \"<s:Envelope xmlns:s=\\\"http://schemas.xmlsoap.org/soap/envelope/\\\" s:encodingStyle=\\\"http://schemas.xmlsoap.org/soap/encoding/\\\">\" +\n\t\t\t\t\"<s:Body>\" +\n\t\t\t\t\"<u:GetExternalIPAddressResponse xmlns:u=\\\"urn:schemas-upnp-org:service:WANIPConnection:1\\\">\" +\n\t\t\t\t\"<NewExternalIPAddress>1.1.1.1</NewExternalIPAddress>\" +\n\t\t\t\t\"</u:GetExternalIPAddressResponse>\" +\n\t\t\t\t\"</s:Body>\" +\n\t\t\t\t\"</s:Envelope>\";\n\n\t\tvar fakeHTTPServer = new FakeHttpServer(\"/control\", 200, responseBody.getBytes());\n\n\t\tvar response = ControlPoint.getExternalIpAddress(\n\t\t\t\tURI.create(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/control\"),\n\t\t\t\t\"urn:schemas-upnp-org:service:WANIPConnection:1\"\n\t\t);\n\n\t\tassertEquals(\"1.1.1.1\", response);\n\n\t\tfakeHTTPServer.shutdown();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/upnp/DeviceTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport io.xeres.testutils.FakeHttpServer;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.util.ResourceUtils;\n\nimport java.io.IOException;\nimport java.net.InetSocketAddress;\nimport java.nio.ByteBuffer;\nimport java.nio.file.Files;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass DeviceTest\n{\n\t@Test\n\tvoid From_Success() throws IOException\n\t{\n\t\tvar routerReply = Files.readAllBytes(ResourceUtils.getFile(ResourceUtils.CLASSPATH_URL_PREFIX + \"upnp/routers/RT-AC87U.xml\").toPath());\n\t\tvar fakeHTTPServer = new FakeHttpServer(\"/rootDesc.xml\", 200, routerReply);\n\n\t\tvar inetSocketAddress = new InetSocketAddress(fakeHTTPServer.getPort());\n\t\tvar httpuReply = \"HTTP/1.1 200 OK\\n\" +\n\t\t\t\t\"CACHE-CONTROL: max-age=120\\n\" +\n\t\t\t\t\"ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\\n\" +\n\t\t\t\t\"USN: uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d8::urn:schemas-upnp-org:device:InternetGatewayDevice:1\\n\" +\n\t\t\t\t\"EXT:\\n\" +\n\t\t\t\t\"SERVER: AsusWRT/384.13 UPnP/1.1 MiniUPnPd/2.1\\n\" +\n\t\t\t\t\"LOCATION: http://localhost:\" + fakeHTTPServer.getPort() + \"/rootDesc.xml\\n\" +\n\t\t\t\t\"OPT: \\\"http://schemas.upnp.org/upnp/1/0/\\\"; ns=01\\n\" +\n\t\t\t\t\"01-NLS: 1594920600\\n\" +\n\t\t\t\t\"BOOTID.UPNP.ORG: 1594920600\\n\" +\n\t\t\t\t\"CONFIGID.UPNP.ORG: 1337\\n\" +\n\t\t\t\t\"\\n\";\n\n\t\tvar device = Device.from(\n\t\t\t\tinetSocketAddress,\n\t\t\t\tByteBuffer.wrap(httpuReply.getBytes())\n\t\t);\n\t\tassertTrue(device.isValid());\n\t\tassertFalse(device.isInvalid());\n\t\tassertEquals(inetSocketAddress, device.getInetSocketAddress());\n\t\tassertTrue(device.hasLocation());\n\t\tassertEquals(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/rootDesc.xml\", device.getLocationUrl().toString());\n\t\tassertTrue(device.hasServer());\n\t\tassertEquals(\"AsusWRT/384.13 UPnP/1.1 MiniUPnPd/2.1\", device.getServer());\n\t\tassertTrue(device.hasUsn());\n\t\tassertEquals(\"uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d8::urn:schemas-upnp-org:device:InternetGatewayDevice:1\", device.getUsn());\n\n\t\tdevice.addControlPoint();\n\t\tassertTrue(device.hasControlPoint());\n\n\t\tassertTrue(device.hasControlUrl());\n\t\tassertEquals(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/ctl/IPConn\", device.getControlUrl().toString());\n\t\tassertTrue(device.hasManufacturer());\n\t\tassertEquals(\"ASUSTek\", device.getManufacturer());\n\t\tassertEquals(\"http://www.asus.com/\", device.getManufacturerUrl().toString());\n\t\tassertTrue(device.hasModelName());\n\t\tassertEquals(\"RT-AC87U\", device.getModelName());\n\t\tassertTrue(device.hasPresentationUrl());\n\t\tassertEquals(\"http://192.168.1.1:80/\", device.getPresentationUrl().toString());\n\t\tassertTrue(device.hasSerialNumber());\n\t\tassertEquals(\"88:d7:f6:44:f8:d8\", device.getSerialNumber());\n\t\tassertEquals(\"urn:schemas-upnp-org:service:WANIPConnection:1\", device.getServiceType());\n\n\t\tfakeHTTPServer.shutdown();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/upnp/PortMappingTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.net.upnp.Protocol.TCP;\nimport static io.xeres.app.net.upnp.Protocol.UDP;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\n\nclass PortMappingTest\n{\n\t@Test\n\tvoid Compare_Success()\n\t{\n\t\tvar mapping1 = new PortMapping(1025, TCP);\n\t\tvar mapping2 = new PortMapping(1025, TCP);\n\n\t\tassertEquals(mapping1, mapping2);\n\t}\n\n\t@Test\n\tvoid Compare_UnequalPort_Failure()\n\t{\n\t\tvar mapping1 = new PortMapping(1025, TCP);\n\t\tvar mapping2 = new PortMapping(1026, TCP);\n\n\t\tassertNotEquals(mapping1, mapping2);\n\t}\n\n\t@Test\n\tvoid Compare_UnequalProtocols_Failure()\n\t{\n\t\tvar mapping1 = new PortMapping(1025, TCP);\n\t\tvar mapping2 = new PortMapping(1025, UDP);\n\n\t\tassertNotEquals(mapping1, mapping2);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/upnp/SoapTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport io.xeres.testutils.FakeHttpServer;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.HttpStatusCode;\nimport org.xml.sax.SAXException;\n\nimport javax.xml.namespace.NamespaceContext;\nimport javax.xml.parsers.DocumentBuilderFactory;\nimport javax.xml.parsers.ParserConfigurationException;\nimport javax.xml.xpath.XPathException;\nimport javax.xml.xpath.XPathFactory;\nimport javax.xml.xpath.XPathNodes;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.net.HttpURLConnection;\nimport java.net.URI;\nimport java.util.Iterator;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\n\nclass SoapTest\n{\n\tprivate static final String SERVICE_TYPE = \"urn:schemas-upnp-org:service:WANIPConnection:1\";\n\tprivate static final String ACTION = \"AddPortMapping\";\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(Soap.class);\n\t}\n\n\t@Test\n\tvoid SendRequest_Success() throws IOException, ParserConfigurationException, SAXException, XPathException\n\t{\n\t\tString key1 = \"NewExternalPort\", key2 = \"NewProtocol\";\n\t\tString value1 = \"1234\", value2 = \"TCP\";\n\t\tvar fakeHTTPServer = new FakeHttpServer(\"/soaptest.xml\", HttpURLConnection.HTTP_OK, \"OK\".getBytes());\n\n\t\tMap<String, String> args = LinkedHashMap.newLinkedHashMap(2);\n\t\targs.put(key1, value1);\n\t\targs.put(key2, value2);\n\n\t\tvar responseEntity = Soap.sendRequest(URI.create(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/soaptest.xml\"), SERVICE_TYPE, ACTION, args);\n\t\tassertEquals(\"OK\", responseEntity.getBody());\n\n\t\tvar documentBuilderFactory = DocumentBuilderFactory.newInstance();\n\t\tdocumentBuilderFactory.setNamespaceAware(true);\n\t\tvar document = documentBuilderFactory.newDocumentBuilder().parse(new ByteArrayInputStream(fakeHTTPServer.getRequestBody()));\n\t\tassertEquals(\"1.0\", document.getXmlVersion());\n\n\t\tvar xPath = XPathFactory.newInstance().newXPath();\n\t\txPath.setNamespaceContext(createNameSpaceContext(Map.of(\n\t\t\t\t\"s\", \"http://schemas.xmlsoap.org/soap/envelope/\",\n\t\t\t\t\"u\", SERVICE_TYPE)));\n\t\tvar nodes = xPath.evaluateExpression(\"//s:Envelope//s:Body//u:\" + ACTION, document, XPathNodes.class);\n\t\tassertEquals(1, nodes.size());\n\n\t\tassertEquals(\"u:\" + ACTION, nodes.get(0).getNodeName());\n\t\tvar childNodes = nodes.get(0).getChildNodes();\n\t\tassertEquals(key1, childNodes.item(0).getNodeName());\n\t\tassertEquals(value1, childNodes.item(0).getTextContent());\n\t\tassertEquals(key2, childNodes.item(1).getNodeName());\n\t\tassertEquals(value2, childNodes.item(1).getTextContent());\n\n\t\tfakeHTTPServer.shutdown();\n\t}\n\n\t@Test\n\tvoid SendRequest_Error()\n\t{\n\t\tString key1 = \"NewExternalPort\", key2 = \"NewProtocol\";\n\t\tString value1 = \"1234\", value2 = \"TCP\";\n\t\tvar fakeHTTPServer = new FakeHttpServer(\"/soaptest.xml\", HttpURLConnection.HTTP_BAD_REQUEST, \"Error\".getBytes());\n\n\t\tMap<String, String> args = LinkedHashMap.newLinkedHashMap(2);\n\t\targs.put(key1, value1);\n\t\targs.put(key2, value2);\n\n\t\tvar responseEntity = Soap.sendRequest(URI.create(\"http://localhost:\" + fakeHTTPServer.getPort() + \"/soaptest.xml\"), SERVICE_TYPE, ACTION, args);\n\t\tassertEquals(HttpStatusCode.valueOf(400), responseEntity.getStatusCode());\n\t\tassertNull(responseEntity.getBody());\n\n\t}\n\n\tprivate NamespaceContext createNameSpaceContext(Map<String, String> uris)\n\t{\n\t\treturn new NamespaceContext()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic String getNamespaceURI(String prefix)\n\t\t\t{\n\t\t\t\treturn uris.get(prefix);\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic String getPrefix(String namespaceURI)\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic Iterator<String> getPrefixes(String namespaceURI)\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/upnp/UPNPServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.upnp;\n\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.time.Duration;\n\nimport static org.awaitility.Awaitility.await;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\n\n@ExtendWith(MockitoExtension.class)\nclass UPNPServiceTest\n{\n\t@Mock\n\tprivate StatusNotificationService statusNotificationService;\n\n\t@InjectMocks\n\tprivate UPNPService upnpService;\n\n\t@Test\n\tvoid StartStop_Success()\n\t{\n\t\tupnpService.start(\"127.0.0.1\", 1901, 0); // nothing should reply in there\n\t\tawait().atMost(Duration.ofSeconds(2)).until(() -> upnpService.isRunning());\n\n\t\tupnpService.stop();\n\t\tupnpService.waitForTermination();\n\t\tassertFalse(upnpService.isRunning());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/net/util/NetworkModeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.net.util;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.net.util.NetworkMode.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass NetworkModeTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, PUBLIC.ordinal());\n\t\tassertEquals(1, PRIVATE.ordinal());\n\t\tassertEquals(2, INVERTED.ordinal());\n\t\tassertEquals(3, DARKNET.ordinal());\n\n\t\tassertEquals(4, values().length);\n\t}\n\n\t@Test\n\tvoid IsDiscoverable()\n\t{\n\t\tassertTrue(isDiscoverable(PUBLIC));\n\t\tassertTrue(isDiscoverable(PRIVATE));\n\t\tassertFalse(isDiscoverable(INVERTED));\n\t\tassertFalse(isDiscoverable(DARKNET));\n\t}\n\n\t@Test\n\tvoid HasDht()\n\t{\n\t\tassertTrue(hasDht(PUBLIC));\n\t\tassertTrue(hasDht(INVERTED));\n\t\tassertFalse(hasDht(PRIVATE));\n\t\tassertFalse(hasDht(DARKNET));\n\t}\n\n\t@Test\n\tvoid GetNetworkMode()\n\t{\n\t\tassertEquals(PUBLIC, getNetworkMode(2, 2));\n\t\tassertEquals(PRIVATE, getNetworkMode(2, 0));\n\t\tassertEquals(INVERTED, getNetworkMode(0, 2));\n\t\tassertEquals(DARKNET, getNetworkMode(0, 0));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/CapabilityServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.application.autostart.AutoStart;\nimport io.xeres.common.rest.config.Capabilities;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass CapabilityServiceTest\n{\n\t@Mock\n\tprivate AutoStart autoStart;\n\n\t@InjectMocks\n\tprivate CapabilityService capabilityService;\n\n\t@Test\n\tvoid GetCapabilities_Success()\n\t{\n\t\twhen(autoStart.isSupported()).thenReturn(true);\n\n\t\tvar capabilities = capabilityService.getCapabilities();\n\n\t\tassertTrue(capabilities.contains(Capabilities.AUTOSTART));\n\n\t\tverify(autoStart).isSupported();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/ContactServiceTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.location.Availability;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass ContactServiceTest\n{\n\t@Mock\n\tprivate ProfileService profileService;\n\n\t@Mock\n\tprivate IdentityService identityService;\n\n\t@InjectMocks\n\tprivate ContactService contactService;\n\n\t@Test\n\tvoid getContacts_ShouldReturnCombinedList()\n\t{\n\t\tvar profile = ProfileFakes.createProfile(\"Test Profile\", 1L);\n\t\tprofile.setAccepted(true);\n\n\t\tvar identity = new IdentityGroupItem();\n\t\tidentity.setId(2L);\n\t\tidentity.setName(\"Test Identity\");\n\t\tidentity.setProfile(profile);\n\n\t\twhen(profileService.getAllProfiles()).thenReturn(List.of(profile));\n\t\twhen(identityService.getAll()).thenReturn(List.of(identity));\n\n\t\tvar result = contactService.getContacts();\n\n\t\tassertEquals(2, result.size());\n\t\tassertTrue(result.stream().anyMatch(c -> c.name().equals(\"Test Profile\")));\n\t\tassertTrue(result.stream().anyMatch(c -> c.name().equals(\"Test Identity\")));\n\t}\n\n\t@Test\n\tvoid toContacts_WithIdentityList_ShouldConvertCorrectly()\n\t{\n\t\tvar profile = ProfileFakes.createOwnProfile();\n\t\tprofile.setAccepted(true);\n\n\t\tvar identity = new IdentityGroupItem();\n\t\tidentity.setId(2L);\n\t\tidentity.setName(\"Test Identity\");\n\t\tidentity.setProfile(profile);\n\n\t\tvar result = contactService.toContacts(List.of(identity));\n\n\t\tassertEquals(1, result.size());\n\t\tassertEquals(\"Test Identity\", result.getFirst().name());\n\t\tassertEquals(1L, result.getFirst().profileId());\n\t\tassertEquals(2L, result.getFirst().identityId());\n\t\tassertTrue(result.getFirst().accepted());\n\t}\n\n\t@Test\n\tvoid toContact_WithProfile_ShouldConvertCorrectly()\n\t{\n\t\tvar profile = ProfileFakes.createOwnProfile();\n\t\tprofile.setAccepted(true);\n\n\t\tvar result = contactService.toContact(profile);\n\n\t\tassertEquals(profile.getName(), result.name());\n\t\tassertEquals(1L, result.profileId());\n\t\tassertEquals(0L, result.identityId());\n\t\tassertTrue(result.accepted());\n\t}\n\n\t@Test\n\tvoid getAvailability_WithNullProfile_ShouldReturnOffline()\n\t{\n\t\tvar identity = new IdentityGroupItem();\n\t\tidentity.setName(\"Test Identity\");\n\n\t\tvar result = contactService.toContacts(List.of(identity));\n\n\t\tassertEquals(Availability.OFFLINE, result.getFirst().availability());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/ForumMessageServiceTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.gxs.ForumMessageItemFakes;\nimport io.xeres.app.xrs.service.forum.ForumRsService;\nimport io.xeres.app.xrs.service.forum.item.ForumMessageItem;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.testutils.IdFakes;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.data.domain.PageImpl;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass ForumMessageServiceTest\n{\n\t@Mock\n\tprivate ForumRsService forumRsService;\n\n\t@Mock\n\tprivate IdentityService identityService;\n\n\t@InjectMocks\n\tprivate ForumMessageService forumMessageService;\n\n\t@Test\n\tvoid getAuthorsMapFromSummaries_ShouldReturnCorrectMap()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar summary = ForumMessageItemFakes.createForumMessageItemSummary(IdFakes.createMsgId(), gxsId, null);\n\t\tvar identityGroupItem = new IdentityGroupItem();\n\t\tidentityGroupItem.setGxsId(gxsId);\n\n\t\twhen(identityService.findAll(Set.of(gxsId)))\n\t\t\t\t.thenReturn(List.of(identityGroupItem));\n\n\t\tMap<GxsId, IdentityGroupItem> result = forumMessageService.getAuthorsMapFromSummaries(new PageImpl<>(List.of(summary)));\n\n\t\tassertNotNull(result);\n\t\tassertEquals(1, result.size());\n\t\tassertTrue(result.containsKey(gxsId));\n\t\tassertEquals(identityGroupItem, result.get(gxsId));\n\t}\n\n\t@Test\n\tvoid getAuthorsMapFromMessages_ShouldReturnCorrectMap()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar message = ForumMessageItemFakes.createForumMessageItem();\n\t\tmessage.setAuthorGxsId(gxsId);\n\t\tvar identityGroupItem = new IdentityGroupItem();\n\t\tidentityGroupItem.setGxsId(gxsId);\n\n\t\twhen(identityService.findAll(Set.of(gxsId)))\n\t\t\t\t.thenReturn(List.of(identityGroupItem));\n\n\t\tMap<GxsId, IdentityGroupItem> result = forumMessageService.getAuthorsMapFromMessages(List.of(message));\n\n\t\tassertNotNull(result);\n\t\tassertEquals(1, result.size());\n\t\tassertTrue(result.containsKey(gxsId));\n\t\tassertEquals(identityGroupItem, result.get(gxsId));\n\t}\n\n\t@Test\n\tvoid getMessagesMapFromSummaries_ShouldReturnCorrectMap()\n\t{\n\t\tvar msgId = IdFakes.createMsgId();\n\t\tvar parentMsgId = IdFakes.createMsgId();\n\t\tvar groupId = 1L;\n\n\t\tvar summary = ForumMessageItemFakes.createForumMessageItemSummary(msgId, null, parentMsgId);\n\n\t\tvar message = new ForumMessageItem();\n\t\tmessage.setMsgId(msgId);\n\n\t\twhen(forumRsService.findAllMessages(groupId, Set.of(msgId, parentMsgId)))\n\t\t\t\t.thenReturn(List.of(message));\n\n\t\tMap<MsgId, ForumMessageItem> result = forumMessageService.getMessagesMapFromSummaries(groupId, new PageImpl<>(List.of(summary)));\n\n\t\tassertNotNull(result);\n\t\tassertEquals(1, result.size());\n\t\tassertTrue(result.containsKey(msgId));\n\t\tassertEquals(message, result.get(msgId));\n\t}\n\n\t@Test\n\tvoid getMessagesMapFromMessages_WithGroupId_ShouldReturnCorrectMap()\n\t{\n\t\tvar msgId = IdFakes.createMsgId();\n\t\tvar parentMsgId = IdFakes.createMsgId();\n\t\tvar groupId = 1L;\n\n\t\tvar message = new ForumMessageItem();\n\t\tmessage.setMsgId(msgId);\n\t\tmessage.setParentMsgId(parentMsgId);\n\n\t\twhen(forumRsService.findAllMessages(groupId, Set.of(msgId, parentMsgId)))\n\t\t\t\t.thenReturn(List.of(message));\n\n\t\tMap<MsgId, ForumMessageItem> result = forumMessageService.getMessagesMapFromMessages(groupId, List.of(message));\n\n\t\tassertNotNull(result);\n\t\tassertEquals(1, result.size());\n\t\tassertTrue(result.containsKey(msgId));\n\t\tassertEquals(message, result.get(msgId));\n\t}\n\n\t@Test\n\tvoid getMessagesMapFromMessages_WithoutGroupId_ShouldReturnCorrectMap()\n\t{\n\t\tvar msgId = IdFakes.createMsgId();\n\t\tvar parentMsgId = IdFakes.createMsgId();\n\n\t\tvar message = new ForumMessageItem();\n\t\tmessage.setMsgId(msgId);\n\t\tmessage.setParentMsgId(parentMsgId);\n\n\t\twhen(forumRsService.findAllMessages(Set.of(msgId, parentMsgId)))\n\t\t\t\t.thenReturn(List.of(message));\n\n\t\tMap<MsgId, ForumMessageItem> result = forumMessageService.getMessagesMapFromMessages(List.of(message));\n\n\t\tassertNotNull(result);\n\t\tassertEquals(1, result.size());\n\t\tassertTrue(result.containsKey(msgId));\n\t\tassertEquals(message, result.get(msgId));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/GeoIpServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport com.maxmind.geoip2.DatabaseReader;\nimport com.maxmind.geoip2.exception.GeoIp2Exception;\nimport com.maxmind.geoip2.model.CountryResponse;\nimport com.maxmind.geoip2.record.Country;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.io.IOException;\nimport java.net.InetAddress;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass GeoIpServiceTest\n{\n\t@Mock\n\tprivate DatabaseReader databaseReader;\n\n\t@InjectMocks\n\tprivate GeoIpService geoIpService;\n\n\t@Test\n\tvoid GetCountry_Success() throws IOException, GeoIp2Exception\n\t{\n\t\tvar address = \"1.1.1.1\";\n\t\tvar inetAddress = InetAddress.getByName(address);\n\n\t\twhen(databaseReader.country(inetAddress)).thenReturn(new CountryResponse(null, new Country(null, null, null, false, \"CH\", null), null, null, null, null));\n\n\t\tvar country = geoIpService.getCountry(address);\n\n\t\tassertEquals(io.xeres.common.geoip.Country.CH, country);\n\n\t\tverify(databaseReader).country(inetAddress);\n\t}\n\n\t@Test\n\tvoid GetCountry_Failure() throws IOException, GeoIp2Exception\n\t{\n\t\tvar address = \"1.1.1.1\";\n\t\tvar inetAddress = InetAddress.getByName(address);\n\n\t\twhen(databaseReader.country(inetAddress)).thenThrow(new GeoIp2Exception(\"Country not found\"));\n\n\t\tvar country = geoIpService.getCountry(address);\n\n\t\tassertNull(country);\n\n\t\tverify(databaseReader).country(inetAddress);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/LocationServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.database.model.connection.ConnectionFakes;\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.database.repository.LocationRepository;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.bouncycastle.openpgp.PGPException;\nimport org.bouncycastle.openpgp.PGPSecretKey;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.domain.Slice;\nimport org.springframework.data.domain.SliceImpl;\n\nimport java.io.IOException;\nimport java.net.InetSocketAddress;\nimport java.security.KeyPair;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.Security;\nimport java.security.cert.CertificateException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\n\nimport static io.xeres.app.net.protocol.PeerAddress.Type.IPV4;\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass LocationServiceTest\n{\n\t@Mock\n\tprivate SettingsService settingsService;\n\n\t@Mock\n\tprivate ProfileService profileService;\n\n\t@Mock\n\tprivate LocationRepository locationRepository;\n\n\t@Mock\n\tprivate ApplicationEventPublisher publisher;\n\n\t@InjectMocks\n\tprivate LocationService locationService;\n\n\tprivate static PGPSecretKey pgpSecretKey;\n\tprivate static KeyPair keyPair;\n\tprivate static Profile ownProfile;\n\n\t@BeforeAll\n\tstatic void setup() throws PGPException\n\t{\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\n\t\tpgpSecretKey = PGP.generateSecretKey(\"test\", \"\", 512);\n\t\tkeyPair = RSA.generateKeys(512);\n\t\townProfile = Profile.createProfile(\"test\", pgpSecretKey.getKeyID(), pgpSecretKey.getPublicKey().getCreationTime().toInstant(), new ProfileFingerprint(pgpSecretKey.getPublicKey().getFingerprint()), pgpSecretKey.getPublicKey());\n\t}\n\n\t@Test\n\tvoid LocationService_GenerateLocationKeys_Success()\n\t{\n\t\twhen(settingsService.getLocationPrivateKeyData()).thenReturn(null);\n\n\t\tassertNotNull(locationService.generateLocationKeys());\n\n\t\tverify(settingsService).getLocationPrivateKeyData();\n\t}\n\n\t@Test\n\tvoid GenerateLocationKeys_LocationAlreadyExists_Success()\n\t{\n\t\twhen(settingsService.getLocationPrivateKeyData()).thenReturn(new byte[]{1});\n\n\t\tassertNull(locationService.generateLocationKeys());\n\n\t\tverify(settingsService, never()).saveLocationKeys(any(KeyPair.class));\n\t}\n\n\t@Test\n\tvoid GenerateLocationCertificate_Success() throws NoSuchAlgorithmException, CertificateException, InvalidKeySpecException, IOException\n\t{\n\t\twhen(settingsService.getSecretProfileKey()).thenReturn(pgpSecretKey.getEncoded());\n\t\twhen(profileService.getOwnProfile()).thenReturn(ownProfile);\n\n\t\tassertNotNull(locationService.generateLocationCertificate(keyPair.getPublic().getEncoded()));\n\n\t}\n\n\t@Test\n\tvoid CreateLocation_Success() throws IOException\n\t{\n\t\twhen(settingsService.isOwnProfilePresent()).thenReturn(true);\n\t\twhen(profileService.getOwnProfile()).thenReturn(ownProfile);\n\t\twhen(settingsService.getSecretProfileKey()).thenReturn(pgpSecretKey.getEncoded());\n\t\twhen(settingsService.getLocationCertificate()).thenReturn(keyPair.getPublic().getEncoded());\n\t\twhen(profileService.getOwnProfile()).thenReturn(ownProfile);\n\n\t\tlocationService.generateOwnLocation(\"test\");\n\n\t\tverify(settingsService, times(1)).isOwnProfilePresent();\n\t\tverify(profileService, times(2)).getOwnProfile();\n\t}\n\n\t@Test\n\tvoid GetConnectionsToConnectTo_Success()\n\t{\n\t\tvar now = Instant.now();\n\n\t\t// Own location\n\t\tvar ownLocation = LocationFakes.createOwnLocation();\n\t\townLocation.addConnection(ConnectionFakes.createConnection(IPV4, \"2.3.4.5:1234\", true));\n\n\t\t// First location with 1 connection\n\t\tvar location1 = LocationFakes.createLocation(\"test1\", ownProfile);\n\t\tlocation1.addConnection(ConnectionFakes.createConnection());\n\n\t\t// Second location with 3 connections\n\t\tvar location2 = LocationFakes.createLocation(\"test2\", ownProfile);\n\t\tvar oldConnection = ConnectionFakes.createConnection();\n\t\tvar recentConnection = ConnectionFakes.createConnection();\n\t\tvar nullConnection = ConnectionFakes.createConnection();\n\t\toldConnection.setLastConnected(now.minus(Duration.ofDays(1)));\n\t\tlocation2.addConnection(oldConnection);\n\t\trecentConnection.setLastConnected(now);\n\t\tlocation2.addConnection(recentConnection);\n\t\tlocation2.addConnection(nullConnection);\n\n\t\tvar locations = List.of(location1, location2);\n\t\tSlice<Location> slice = new SliceImpl<>(locations);\n\t\twhen(locationRepository.findAllByConnectedFalse(any(Pageable.class))).thenReturn(slice);\n\n\t\twhen(locationRepository.findById(OWN_LOCATION_ID)).thenReturn(Optional.of(ownLocation));\n\n\t\t// First run\n\t\tvar connections = locationService.getConnectionsToConnectTo(10);\n\t\tassertEquals(2, connections.size());\n\t\tassertEquals(location1.getConnections().getFirst(), connections.get(0));\n\t\tassertEquals(recentConnection, connections.get(1));\n\n\t\t// Second run\n\t\tconnections = locationService.getConnectionsToConnectTo(10);\n\t\tassertEquals(2, connections.size());\n\t\tassertEquals(location1.getConnections().getFirst(), connections.get(0));\n\t\tassertEquals(oldConnection, connections.get(1));\n\n\t\t// Third run\n\t\tconnections = locationService.getConnectionsToConnectTo(10);\n\t\tassertEquals(2, connections.size());\n\t\tassertEquals(location1.getConnections().getFirst(), connections.get(0));\n\t\tassertEquals(nullConnection, connections.get(1));\n\t}\n\n\t@Test\n\tvoid GetConnectionsToConnectTo_PreferLAN()\n\t{\n\t\tvar now = Instant.now();\n\n\t\t// Own location\n\t\tvar ownLocation = LocationFakes.createOwnLocation();\n\t\townLocation.addConnection(ConnectionFakes.createConnection(IPV4, \"2.3.4.5:1234\", true));\n\n\t\t// First location with 1 connection, same address\n\t\tvar location1 = LocationFakes.createLocation(\"test1\", ownProfile);\n\t\tlocation1.addConnection(ConnectionFakes.createConnection(IPV4, \"2.3.4.5:1234\", true));\n\n\t\t// Second location with 2 connections, one same, one LAN\n\t\tvar location2 = LocationFakes.createLocation(\"test2\", ownProfile);\n\t\tvar wanConnection = ConnectionFakes.createConnection(IPV4, \"2.3.4.5:1234\", true);\n\t\tvar lanConnection = ConnectionFakes.createConnection(IPV4, \"192.168.1.25:1234\", false);\n\t\twanConnection.setLastConnected(now);\n\t\tlocation2.addConnection(wanConnection);\n\t\tlanConnection.setLastConnected(now);\n\t\tlocation2.addConnection(lanConnection);\n\n\t\tvar locations = List.of(location1, location2);\n\t\tSlice<Location> slice = new SliceImpl<>(locations);\n\t\twhen(locationRepository.findAllByConnectedFalse(any(Pageable.class))).thenReturn(slice);\n\n\t\twhen(locationRepository.findById(OWN_LOCATION_ID)).thenReturn(Optional.of(ownLocation));\n\n\t\t// First run\n\t\tvar connections = locationService.getConnectionsToConnectTo(10);\n\t\tassertEquals(2, connections.size());\n\t\tassertEquals(location1.getConnections().getFirst(), connections.get(0));\n\t\tassertEquals(lanConnection, connections.get(1));\n\n\t\t// Second run\n\t\tconnections = locationService.getConnectionsToConnectTo(10);\n\t\tassertEquals(2, connections.size());\n\t\tassertEquals(location1.getConnections().getFirst(), connections.get(0));\n\t\tassertEquals(wanConnection, connections.get(1));\n\t}\n\n\t@Test\n\tvoid SetConnected_Success()\n\t{\n\t\tvar location = LocationFakes.createLocation(\"foo\", ProfileFakes.createProfile(\"foo\", 1));\n\n\t\tlocationService.setConnected(location, new InetSocketAddress(\"127.0.0.1\", 666));\n\n\t\tassertTrue(location.isConnected());\n\t}\n\n\t@Test\n\tvoid SetDisconnected_Success()\n\t{\n\t\tvar location = LocationFakes.createLocation(\"foo\", ProfileFakes.createProfile(\"foo\", 1));\n\t\tlocation.setConnected(true);\n\n\t\tlocationService.setDisconnected(location);\n\n\t\tassertFalse(location.isConnected());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/ProfileServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.Profile;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.database.repository.ProfileRepository;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.common.dto.profile.ProfileConstants;\nimport io.xeres.common.id.ProfileFingerprint;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.security.Security;\nimport java.util.Optional;\n\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass ProfileServiceTest\n{\n\t@Mock\n\tprivate SettingsService settingsService;\n\n\t@Mock\n\tprivate ProfileRepository profileRepository;\n\n\t@Mock\n\tprivate ContactNotificationService contactNotificationService;\n\n\t@InjectMocks\n\tprivate ProfileService profileService;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\t}\n\n\t@Test\n\tvoid GenerateProfileKeys_Success()\n\t{\n\t\tvar name = \"test\";\n\n\t\tassertEquals(ResourceCreationState.CREATED, profileService.generateProfileKeys(name));\n\n\t\tvar profile = ArgumentCaptor.forClass(Profile.class);\n\t\tverify(profileRepository).save(profile.capture());\n\t\tassertTrue(profile.getValue().getName().startsWith(name));\n\t\tverify(settingsService).saveSecretProfileKey(any(byte[].class));\n\t}\n\n\t@Test\n\tvoid GenerateProfileKeys_AlreadyExists_Failure()\n\t{\n\t\tvar name = \"test\";\n\n\t\twhen(profileRepository.findById(ProfileConstants.OWN_PROFILE_ID)).thenReturn(Optional.of(ProfileFakes.createProfile()));\n\n\t\tassertEquals(ResourceCreationState.ALREADY_EXISTS, profileService.generateProfileKeys(name));\n\n\t\tverify(profileRepository, never()).save(any(Profile.class));\n\t\tverify(settingsService, never()).saveSecretProfileKey(any(byte[].class));\n\t}\n\n\t@Test\n\tvoid GenerateProfileKeys_KeyIdTooShort_Failure()\n\t{\n\t\tvar name = \"\";\n\n\t\tassertThatThrownBy(() -> profileService.generateProfileKeys(name))\n\t\t\t\t.isInstanceOf(IllegalArgumentException.class)\n\t\t\t\t.hasMessageContaining(\"too short\");\n\n\t\tverify(profileRepository, never()).save(any(Profile.class));\n\t\tverify(settingsService, never()).saveSecretProfileKey(any(byte[].class));\n\t}\n\n\t@Test\n\tvoid GenerateProfileKeys_KeyIdTooLong_Failure()\n\t{\n\t\tvar name = \"12345678900987654321123456789098765432120987676543432123456798765\";\n\n\t\tassertThatThrownBy(() -> profileService.generateProfileKeys(name))\n\t\t\t\t.isInstanceOf(IllegalArgumentException.class)\n\t\t\t\t.hasMessageContaining(\"too long\");\n\n\t\tverify(profileRepository, never()).save(any(Profile.class));\n\t\tverify(settingsService, never()).saveSecretProfileKey(any(byte[].class));\n\t}\n\n\t@Test\n\tvoid CreateOrUpdateProfile_Update_Success()\n\t{\n\t\tvar first = ProfileFakes.createProfile(\"first\", 1);\n\t\tfirst.addLocation(LocationFakes.createLocation(\"first location\", first));\n\n\t\tvar second = ProfileFakes.createProfile(\"first\", 1);\n\t\tsecond.addLocation(LocationFakes.createLocation(\"second location\", second));\n\n\t\twhen(profileRepository.findByProfileFingerprint(any(ProfileFingerprint.class))).thenReturn(Optional.of(first));\n\t\twhen(profileRepository.save(any(Profile.class))).thenAnswer(mock -> mock.getArguments()[0]);\n\n\t\tvar updated = profileService.createOrUpdateProfile(second);\n\n\t\tassertEquals(2, updated.getLocations().size());\n\n\t\t// XXX: add the case where we \"update\" an existing location, not just add\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/QrCodeServiceTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport com.google.zxing.BinaryBitmap;\nimport com.google.zxing.MultiFormatReader;\nimport com.google.zxing.NotFoundException;\nimport com.google.zxing.client.j2se.BufferedImageLuminanceSource;\nimport com.google.zxing.common.HybridBinarizer;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\n@ExtendWith(MockitoExtension.class)\nclass QrCodeServiceTest\n{\n\t@InjectMocks\n\tprivate QrCodeService qrCodeService;\n\n\t@Test\n\tvoid GenerateQrCode_Success() throws NotFoundException\n\t{\n\t\tvar message = \"hello world\";\n\n\t\tvar image = qrCodeService.generateQrCode(message);\n\n\t\tvar source = new BufferedImageLuminanceSource(image);\n\t\tvar bitmap = new BinaryBitmap(new HybridBinarizer(source));\n\n\t\tvar result = new MultiFormatReader().decode(bitmap);\n\n\t\tassertEquals(message, result.getText());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/ServiceRulesTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\nimport com.tngtech.archunit.lang.ArchRule;\nimport org.springframework.stereotype.Service;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;\n\n@AnalyzeClasses()\nclass ServiceRulesTest\n{\n\t@ArchTest\n\tprivate final ArchRule servicesShouldHaveSuffix = classes()\n\t\t\t.that().resideInAPackage(\"..service..\")\n\t\t\t.and().areAnnotatedWith(Service.class)\n\t\t\t.should().haveSimpleNameEndingWith(\"Service\");\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/SettingsServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport io.xeres.app.database.model.settings.Settings;\nimport io.xeres.app.database.repository.SettingsRepository;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.Optional;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass SettingsServiceTest\n{\n\t@Mock\n\tprivate SettingsRepository settingsRepository;\n\n\t@Mock\n\tprivate Settings settings;\n\n\t@InjectMocks\n\tprivate SettingsService settingsService;\n\n\t@Test\n\tvoid SaveSecretProfileKey_Success()\n\t{\n\t\twhen(settingsRepository.findById((byte) 1)).thenReturn(Optional.of(settings));\n\t\tsettingsService.init();\n\n\t\tsettingsService.saveSecretProfileKey(new byte[]{1});\n\n\t\tverify(settings).setPgpPrivateKeyData(any(byte[].class));\n\t\tverify(settingsRepository).save(any(Settings.class));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/UnHtmlServiceTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\n@ExtendWith(MockitoExtension.class)\nclass UnHtmlServiceTest\n{\n\t@InjectMocks\n\tprivate UnHtmlService unHtmlService;\n\n\t@Test\n\tvoid UnchangedMessage()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"foo\");\n\n\t\tassertEquals(\"foo\", result);\n\t}\n\n\t@Test\n\tvoid QuotedMessage()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t<body><style type=\"text/css\" RSOptimized=\"v2\">.S0{font-size:11pt;}.S0{font-style:normal;}.S0{font-family:'MSSansSerif';}.S0{font-weight:400;}</style><span><span class=\"S0\">> this is a quote<br/><br/>and that's the reply</span></span></body>\n\t\t\t\t\"\"\");\n\n\t\tassertEquals(\"\"\"\n\t\t\t\t\\\\> this is a quote \\s\n\t\t\t\t \\s\n\t\t\t\tand that's the reply\n\t\t\t\t\"\"\", result);\n\t}\n\n\t@Test\n\tvoid MultiLevelQuotes()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t>>> third\n\t\t\t\t\n\t\t\t\t>> second\n\t\t\t\t\n\t\t\t\t> first\n\t\t\t\t\n\t\t\t\tnot a quote\n\t\t\t\t\"\"\");\n\n\t\tassertEquals(\"\"\"\n\t\t\t\t>>> third\n\t\t\t\t\n\t\t\t\t>> second\n\t\t\t\t\n\t\t\t\t> first\n\t\t\t\t\n\t\t\t\tnot a quote\n\t\t\t\t\"\"\", result);\n\t}\n\n\t@Test\n\tvoid Pre()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t<body><style type=\"text/css\" RSOptimized=\"v2\">.S1{margin-bottom:16px;}.S2{font-size:10pt;}.S1{margin-left:0px;}.S2{font-family:'SansSerif';}.S2{font-style:normal;}.S1{-qt-block-indent:0;}.S0{background-color:transparent;}.S2{font-weight:400;}.S0{color:#1f2328;}.S1{text-indent:0px;}.S1{background-color:#f6f8fa;}.S1{margin-top:0px;}.S1{line-height:145%;}.S0{font-family:'ui-monospace','SFMono-Regular','SFMono','Menlo','Consolas','LiberationMono','monospace';}.S1{margin-right:0px;}</style><span><span class=\"S2\"><pre class=\"S1\"><span class=\"S0\">flatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref</span></pre></span></span></body>\"\"\");\n\n\n\t\tassertEquals(\"```\\nflatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref\\n```\\n\", result);\n\t}\n\n\t@Test\n\tvoid Code()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t<body><style type=\"text/css\" RSOptimized=\"v2\">.S1{margin-bottom:16px;}.S2{font-size:10pt;}.S1{margin-left:0px;}.S2{font-family:'SansSerif';}.S2{font-style:normal;}.S1{-qt-block-indent:0;}.S0{background-color:transparent;}.S2{font-weight:400;}.S0{color:#1f2328;}.S1{text-indent:0px;}.S1{background-color:#f6f8fa;}.S1{margin-top:0px;}.S1{line-height:145%;}.S0{font-family:'ui-monospace','SFMono-Regular','SFMono','Menlo','Consolas','LiberationMono','monospace';}.S1{margin-right:0px;}</style><span><span class=\"S2\"><code class=\"S1\"><span class=\"S0\">flatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref</span></code></span></span></body>\"\"\");\n\n\n\t\tassertEquals(\"`flatpak install --user https://dl.flathub.org/build-repo/189725/cc.retroshare.retroshare-gui.flatpakref`\\n\", result);\n\t}\n\n\t@Test\n\tvoid CodeWithLanguage()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t<body>\n\t\t\t\t\t<pre>\n\t\t\t\t\t\t<code class=\"language-java\">System.out.println(\"hello world\");</code>\n\t\t\t\t\t</pre>\n\t\t\t\t</body>\n\t\t\t\t\"\"\"\n\t\t);\n\n\t\tassertEquals(\"\"\"\n\t\t\t\t```java\n\t\t\t\tSystem.out.println(\"hello world\");\n\t\t\t\t```\n\t\t\t\t\"\"\", result);\n\t}\n\n\t// This one is not aesthetically important because it will translate to JavaFX nodes later and is\n\t// never visible to the user.\n\t@Test\n\tvoid AllTags()\n\t{\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t<body>\n\t\t\t\t\t<h1>header1</h1>\n\t\t\t\t\t<h2>header2</h2>\n\t\t\t\t\t<h3>header3</h3>\n\t\t\t\t\t<h4>header4</h4>\n\t\t\t\t\t<h5>header5</h5>\n\t\t\t\t\t<h6>header6</h6>\n\t\t\t\t\t<p>Some paragraph</p>\n\t\t\t\t\t<hr>\n\t\t\t\t\t<blockquote>Someone said...</blockquote>\n\t\t\t\t\t<ul>\n\t\t\t\t\t\t<li>First item\n\t\t\t\t\t\t<li>Second item\n\t\t\t\t\t</ul>\n\t\t\t\t\t<ol>\n\t\t\t\t\t\t<li>First item\n\t\t\t\t\t\t<li>Second item\n\t\t\t\t\t</ol>\n\t\t\t\t\t<i>italic</i>, <b>bold</b>, <del>strikethrough</del>, link <a href=\"https://xeres.io\">here</a>\n\t\t\t\t</body>\n\t\t\t\t\"\"\");\n\n\t\tassertEquals(\"\"\"\n\t\t\t\t# header1\n\t\t\t\t\n\t\t\t\t## header2\n\t\t\t\t\n\t\t\t\t### header3\n\t\t\t\t\n\t\t\t\t#### header4\n\t\t\t\t\n\t\t\t\t##### header5\n\t\t\t\t\n\t\t\t\t###### header6\n\t\t\t\t\n\t\t\t\tSome paragraph\n\t\t\t\t\n\t\t\t\t___\n\t\t\t\t\n\t\t\t\t> Someone said...\n\t\t\t\t\n\t\t\t\t- First item - Second item\\s\n\t\t\t\t\n\t\t\t\t1. First item 2. Second item\\s\n\t\t\t\t\n\t\t\t\t*italic*, **bold**, ~strikethrough~, link [here](https://xeres.io \"\")\n\t\t\t\t\"\"\", result);\n\t}\n\n\t@Test\n\tvoid BrokenHtml()\n\t{\n\t\t// Parent of <pre> must be a block, like <pre>, but it's an inline tag. Seen in some RS generated posts.\n\t\tvar result = unHtmlService.cleanupMessage(\"\"\"\n\t\t\t\t<html>\n\t\t\t\t <body>\n\t\t\t\t  <a><pre>foo</pre></a>\n\t\t\t\t </body>\n\t\t\t\t</html>\n\t\t\t\t\"\"\"\n\t\t);\n\t\tassertTrue(result.startsWith(\"## Invalid HTML document\"));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/file/FileServiceTest.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.file;\n\nimport io.xeres.app.configuration.DataDirConfiguration;\nimport io.xeres.app.database.model.file.FileFakes;\nimport io.xeres.app.database.model.share.ShareFakes;\nimport io.xeres.app.database.repository.FileRepository;\nimport io.xeres.app.database.repository.ShareRepository;\nimport io.xeres.app.service.notification.file.FileNotificationService;\nimport io.xeres.common.id.Id;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.boot.logging.LogLevel;\nimport org.springframework.boot.logging.LoggingSystem;\n\nimport java.net.URISyntaxException;\nimport java.nio.file.Path;\nimport java.util.Objects;\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass FileServiceTest\n{\n\t@Mock\n\tprivate FileNotificationService fileNotificationService;\n\n\t@Mock\n\tprivate HashBloomFilter hashBloomFilter;\n\n\t@Mock\n\tprivate DataDirConfiguration dataDirConfiguration;\n\n\t@Mock\n\tprivate FileRepository fileRepository;\n\n\t@Mock\n\tprivate ShareRepository shareRepository;\n\n\t@InjectMocks\n\tprivate FileService fileService;\n\n\t@BeforeAll\n\tstatic void setErrorLogging()\n\t{\n\t\tLoggingSystem.get(ClassLoader.getSystemClassLoader()).setLogLevel(\"io.xeres\", LogLevel.DEBUG);\n\t}\n\n\t@Test\n\tvoid HashFile_Success() throws URISyntaxException\n\t{\n\t\tvar ioBuffer = new byte[FileService.SMALL_FILE_SIZE];\n\n\t\t// mmap (> 16 KB file)\n\t\tvar hash = fileService.calculateFileHash(Path.of(Objects.requireNonNull(FileServiceTest.class.getResource(\"/image/leguman.jpg\")).toURI()), ioBuffer);\n\t\tassertNotNull(hash);\n\t\tassertEquals(\"0f02355b1b1e9a22801dddd85ded59fe7301698d\", Id.toString(hash.getBytes()));\n\n\t\t// non mmap (<= 16 KB file)\n\t\thash = fileService.calculateFileHash(Path.of(Objects.requireNonNull(FileServiceTest.class.getResource(\"/upnp/routers/RT-AC87U.xml\")).toURI()), ioBuffer);\n\t\tassertNotNull(hash);\n\t\tassertEquals(\"a045c2c987b55e6c29082ded01a9abf33ad4cf9d\", Id.toString(hash.getBytes()));\n\t}\n\n\t@Test\n\tvoid ScanShare_Success() throws URISyntaxException\n\t{\n\t\tvar share = ShareFakes.createShare(Path.of(Objects.requireNonNull(FileServiceTest.class.getResource(\"/image\")).toURI()));\n\t\tfileService.scanShare(share);\n\t\tverify(fileNotificationService).startScanning(share);\n\t\tverify(fileNotificationService, times(2)).startScanningFile(any());\n\t\tverify(fileNotificationService, times(2)).stopScanningFile();\n\t\tverify(fileNotificationService).stopScanning();\n\t}\n\n\t@Test\n\tvoid DeleteFile_SingleFile_Success()\n\t{\n\t\t// Root\n\t\tvar fileRoot = FileFakes.createFile(\"C:\\\\\", null);\n\n\t\t// Share\n\t\tvar fileGreatGrandParent = FileFakes.createFile(\"share\", fileRoot);\n\t\tvar share = ShareFakes.createShare(fileGreatGrandParent);\n\n\t\tvar fileGrandParent = FileFakes.createFile(\"media\", fileGreatGrandParent);\n\n\t\tvar fileParent = FileFakes.createFile(\"images\", fileGrandParent);\n\n\t\tvar file = FileFakes.createFile(\"foobar.jpg\", fileParent);\n\n\t\t// C:\\share\\media\\images\\foobar.jpg\n\n\t\twhen(fileRepository.countByParent(fileParent)).thenReturn(1);\n\t\twhen(shareRepository.findShareByFile(fileParent)).thenReturn(Optional.empty());\n\n\t\twhen(fileRepository.countByParent(fileGrandParent)).thenReturn(1);\n\t\twhen(shareRepository.findShareByFile(fileGrandParent)).thenReturn(Optional.empty());\n\n\t\twhen(fileRepository.countByParent(fileGreatGrandParent)).thenReturn(1);\n\t\twhen(shareRepository.findShareByFile(fileGreatGrandParent)).thenReturn(Optional.of(share));\n\n\t\tfileService.deleteFile(file);\n\n\t\tverify(fileRepository, never()).countByParent(file);\n\t\tverify(shareRepository, never()).findShareByFile(file);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileParent);\n\t\tverify(shareRepository, times(1)).findShareByFile(fileParent);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileGrandParent);\n\t\tverify(shareRepository, times(1)).findShareByFile(fileGrandParent);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileGreatGrandParent);\n\t\tverify(shareRepository, times(1)).findShareByFile(fileGreatGrandParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileRoot);\n\t\tverify(shareRepository, never()).findShareByFile(fileRoot);\n\n\t\tverify(fileRepository, never()).delete(file);\n\t\tverify(fileRepository, times(1)).delete(fileGrandParent);\n\t}\n\n\t@Test\n\tvoid DeleteFile_TwoFiles_Success()\n\t{\n\t\t// Root\n\t\tvar fileRoot = FileFakes.createFile(\"C:\\\\\", null);\n\n\t\t// Share\n\t\tvar fileGreatGrandParent = FileFakes.createFile(\"share\", fileRoot);\n\n\t\tvar fileGrandParent = FileFakes.createFile(\"media\", fileGreatGrandParent);\n\n\t\tvar fileParent = FileFakes.createFile(\"images\", fileGrandParent);\n\n\t\tvar file = FileFakes.createFile(\"foobar.jpg\", fileParent);\n\n\t\t// C:\\share\\media\\images\\foobar.jpg and plop.jpg\n\n\t\twhen(fileRepository.countByParent(fileParent)).thenReturn(2);\n\n\t\tfileService.deleteFile(file);\n\n\t\tverify(fileRepository, never()).countByParent(file);\n\t\tverify(shareRepository, never()).findShareByFile(file);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileParent);\n\t\tverify(shareRepository, never()).findShareByFile(fileParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileGrandParent);\n\t\tverify(shareRepository, never()).findShareByFile(fileGrandParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileGreatGrandParent);\n\t\tverify(shareRepository, never()).findShareByFile(fileGreatGrandParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileRoot);\n\t\tverify(shareRepository, never()).findShareByFile(fileRoot);\n\n\t\tverify(fileRepository, times(1)).delete(file);\n\t\tverify(fileRepository, never()).delete(fileGrandParent);\n\t}\n\n\t@Test\n\tvoid DeleteFile_SingleFileButAnotherUpper_Success()\n\t{\n\t\t// Root\n\t\tvar fileRoot = FileFakes.createFile(\"C:\\\\\", null);\n\n\t\t// Share\n\t\tvar fileGreatGrandParent = FileFakes.createFile(\"share\", fileRoot);\n\n\t\tvar fileGrandParent = FileFakes.createFile(\"media\", fileGreatGrandParent);\n\n\t\tvar fileParent = FileFakes.createFile(\"images\", fileGrandParent);\n\t\tFileFakes.createFile(\"videos\", fileGrandParent);\n\n\t\tvar file = FileFakes.createFile(\"foobar.jpg\", fileParent);\n\n\t\t// C:\\share\\media\\images\\foobar.jpg and plop.avi is in media\\videos\n\n\t\twhen(fileRepository.countByParent(fileParent)).thenReturn(1);\n\t\twhen(shareRepository.findShareByFile(fileParent)).thenReturn(Optional.empty());\n\n\t\twhen(fileRepository.countByParent(fileGrandParent)).thenReturn(2);\n\n\t\tfileService.deleteFile(file);\n\n\t\tverify(fileRepository, never()).countByParent(file);\n\t\tverify(shareRepository, never()).findShareByFile(file);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileParent);\n\t\tverify(shareRepository, times(1)).findShareByFile(fileParent);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileGrandParent);\n\t\tverify(shareRepository, never()).findShareByFile(fileGrandParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileGreatGrandParent);\n\t\tverify(shareRepository, never()).findShareByFile(fileGreatGrandParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileRoot);\n\t\tverify(shareRepository, never()).findShareByFile(fileRoot);\n\n\t\tverify(fileRepository, never()).delete(file);\n\t\tverify(fileRepository, times(1)).delete(fileParent);\n\t\tverify(fileRepository, never()).delete(fileGrandParent);\n\t}\n\n\t@Test\n\tvoid DeleteFile_SingleFileButNotShare_Success()\n\t{\n\t\t// Root\n\t\tvar fileRoot = FileFakes.createFile(\"C:\\\\\", null);\n\n\t\t// Share\n\t\tvar fileParent = FileFakes.createFile(\"share\", fileRoot);\n\t\tvar share = ShareFakes.createShare(fileParent);\n\n\t\tvar file = FileFakes.createFile(\"foobar.jpg\", fileParent);\n\n\t\t// C:\\share\\foobar.jpg\n\n\t\twhen(fileRepository.countByParent(fileParent)).thenReturn(1);\n\t\twhen(shareRepository.findShareByFile(fileParent)).thenReturn(Optional.of(share));\n\n\t\tfileService.deleteFile(file);\n\n\t\tverify(fileRepository, never()).countByParent(file);\n\t\tverify(shareRepository, never()).findShareByFile(file);\n\n\t\tverify(fileRepository, times(1)).countByParent(fileParent);\n\t\tverify(shareRepository, times(1)).findShareByFile(fileParent);\n\n\t\tverify(fileRepository, never()).countByParent(fileRoot);\n\t\tverify(shareRepository, never()).findShareByFile(fileRoot);\n\n\t\tverify(fileRepository, times(1)).delete(file);\n\t\tverify(fileRepository, never()).delete(fileParent);\n\t\tverify(fileRepository, never()).delete(fileRoot);\n\t}\n\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/shell/HistoryTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.shell;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\n\nclass HistoryTest\n{\n\t@Test\n\tvoid Add_And_Navigate()\n\t{\n\t\tvar history = new History(20);\n\t\thistory.addCommand(\"foo\");\n\t\thistory.addCommand(\"bar\");\n\n\t\tassertEquals(\"bar\", history.getPrevious());\n\t\tassertEquals(\"foo\", history.getPrevious());\n\t\tassertEquals(\"foo\", history.getPrevious());\n\t\tassertEquals(\"bar\", history.getNext());\n\t\tassertNull(history.getNext());\n\t\tassertEquals(\"bar\", history.getPrevious());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/service/shell/ShellServiceTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.service.shell;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static io.xeres.common.mui.ShellAction.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\n@ExtendWith(MockitoExtension.class)\nclass ShellServiceTest\n{\n\t@InjectMocks\n\tprivate ShellService shellService;\n\n\t@Test\n\tvoid translateCommandLine_OK()\n\t{\n\t\tvar res = ShellService.translateCommandline(\"hello world\");\n\t\tassertEquals(\"hello\", res[0]);\n\t\tassertEquals(\"world\", res[1]);\n\t}\n\n\t@Test\n\tvoid sendCommand_Cls()\n\t{\n\t\tvar res = shellService.sendCommand(\"cls\");\n\t\tassertEquals(CLS, res.getAction());\n\t}\n\n\t@Test\n\tvoid sendCommand_Alias_Clear()\n\t{\n\t\tvar res = shellService.sendCommand(\"clear\");\n\t\tassertEquals(CLS, res.getAction());\n\t}\n\n\t@Test\n\tvoid sendCommand_Exit()\n\t{\n\t\tvar res = shellService.sendCommand(\"exit\");\n\t\tassertEquals(EXIT, res.getAction());\n\t}\n\n\t@Test\n\tvoid sendCommand_Help()\n\t{\n\t\tvar res = shellService.sendCommand(\"help\");\n\t\tassertEquals(SUCCESS, res.getAction());\n\t\tassertTrue(res.getOutput().contains(\"Available commands:\"));\n\t}\n\n\t@Test\n\tvoid sendCommand_Unknown()\n\t{\n\t\tvar res = shellService.sendCommand(\"yabadabadoo\");\n\t\tassertEquals(UNKNOWN_COMMAND, res.getAction());\n\t}\n\n\t@Test\n\tvoid sendCommand_NoOp()\n\t{\n\t\tvar res = shellService.sendCommand(\"\");\n\t\tassertEquals(NO_OP, res.getAction());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/util/OsUtilsTest.java",
    "content": "package io.xeres.app.util;\n\nimport io.xeres.common.util.OsUtils;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.nio.file.Path;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass OsUtilsTest\n{\n\t@Test\n\tvoid IsFileSystemCaseSensitive_Success()\n\t{\n\t\tvar tempDir = System.getProperty(\"java.io.tmpdir\");\n\n\t\tvar isCaseSensitive = OsUtils.isFileSystemCaseSensitive(Path.of(tempDir));\n\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\tassertFalse(isCaseSensitive);\n\t\t}\n\t\telse if (SystemUtils.IS_OS_LINUX)\n\t\t{\n\t\t\tassertTrue(isCaseSensitive);\n\t\t}\n\t\telse if (SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\tassertFalse(isCaseSensitive);\n\t\t}\n\t\t// Don't care on other operating systems\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/util/expression/ExpressionCriteriaTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.FileFakes;\nimport io.xeres.app.database.repository.FileRepository;\nimport io.xeres.app.service.file.FileService;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.test.context.web.WebAppConfiguration;\n\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.springframework.boot.test.context.SpringBootTest.UseMainMethod.ALWAYS;\n\n@SpringBootTest(args = \"--no-gui\", useMainMethod = ALWAYS)\n@WebAppConfiguration // see https://stackoverflow.com/questions/73575360/attribute-javax-websocket-server-servercontainer-not-found-in-servletcontext-w\nclass ExpressionCriteriaTest\n{\n\t@Autowired\n\tprivate FileService fileService;\n\n\t@Autowired\n\tprivate FileRepository fileRepository;\n\n\t@Test\n\tvoid Name()\n\t{\n\t\tvar file = FileFakes.createFile(\"The Great Race.mkv\");\n\t\tfileRepository.save(file);\n\n\t\tvar expressionEqualsOk = new NameExpression(StringExpression.Operator.EQUALS, \"The Great Race.mkv\", true);\n\t\tvar expressionEqualsNoCaseOk = new NameExpression(StringExpression.Operator.EQUALS, \"the great race.mkv\", false);\n\t\tvar expressionEqualsCaseFail = new NameExpression(StringExpression.Operator.EQUALS, \"the great race.mkv\", true);\n\t\tvar expressionEqualsFail = new NameExpression(StringExpression.Operator.EQUALS, \"The Great Race\", false);\n\n\t\tvar expressionAllOk = new NameExpression(StringExpression.Operator.CONTAINS_ALL, \"Race Great\", true);\n\t\tvar expressionAllNoCaseOk = new NameExpression(StringExpression.Operator.CONTAINS_ALL, \"race great\", false);\n\t\tvar expressionAllCaseFail = new NameExpression(StringExpression.Operator.CONTAINS_ALL, \"race great\", true);\n\t\tvar expressionAllFail = new NameExpression(StringExpression.Operator.CONTAINS_ALL, \"Race Great Foo\", false);\n\n\t\tvar expressionAnyOk = new NameExpression(StringExpression.Operator.CONTAINS_ANY, \"Race Stuff\", true);\n\t\tvar expressionAnyNoCaseOk = new NameExpression(StringExpression.Operator.CONTAINS_ANY, \"race stuff\", false);\n\t\tvar expressionAnyCaseFail = new NameExpression(StringExpression.Operator.CONTAINS_ANY, \"race stuff\", true);\n\t\tvar expressionAnyFail = new NameExpression(StringExpression.Operator.CONTAINS_ANY, \"Foo Bar Plop\", false);\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName());\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsNoCaseOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsCaseFail)).isEmpty());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionAllOk)).getFirst().getName());\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionAllNoCaseOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionAllCaseFail)).isEmpty());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionAllFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionAnyOk)).getFirst().getName());\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionAnyNoCaseOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionAnyCaseFail)).isEmpty());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionAnyFail)).isEmpty());\n\n\t\tfileRepository.delete(file);\n\t}\n\n\t@Test\n\tvoid Path()\n\t{\n\t\tvar parent = FileFakes.createFile(\"Movies\");\n\t\tfileRepository.save(parent);\n\t\tvar file = FileFakes.createFile(\"The Great Race.mkv\", parent);\n\t\tfileRepository.save(file);\n\n\t\tvar expressionNotSupported = new PathExpression(StringExpression.Operator.EQUALS, \"Movies\", false);\n\n\t\tassertTrue(fileService.searchFiles(List.of(expressionNotSupported)).isEmpty());\n\n\t\tfileRepository.delete(parent);\n\t\tfileRepository.delete(file);\n\t}\n\n\t@Test\n\tvoid Extension()\n\t{\n\t\tvar file = FileFakes.createFile(\"The Empty Bin.EXE\");\n\t\tfileRepository.save(file);\n\n\t\tvar expressionEqualsOk = new ExtensionExpression(StringExpression.Operator.EQUALS, \"EXE\", true);\n\t\tvar expressionEqualsNoCaseOk = new ExtensionExpression(StringExpression.Operator.EQUALS, \"exe\", false);\n\t\tvar expressionEqualsCaseFail = new ExtensionExpression(StringExpression.Operator.EQUALS, \"exe\", true);\n\t\tvar expressionEqualsFail = new ExtensionExpression(StringExpression.Operator.EQUALS, \"bin\", false);\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName());\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsNoCaseOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsCaseFail)).isEmpty());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty());\n\n\t\tfileRepository.delete(file);\n\t}\n\n//\t@Test\n//\tvoid ExpressionCriteria_Hash()\n//\t{\n//\t\tvar file = FileFakes.createFile(\"Stuff\", 1024, Instant.now(), Sha1SumFakes.createSha1Sum());\n//\t\tfileRepository.save(file);\n//\n//\t\tvar expressionEqualsOk = new HashExpression(StringExpression.Operator.EQUALS, file.getHash().toString());\n//\n//\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName());\n//\n//\t    fileRepository.delete(file);\n//\t}\n\n\t@Test\n\tvoid Date()\n\t{\n\t\tvar file = FileFakes.createFile(\"Foobar\", 1024, Instant.now().truncatedTo(ChronoUnit.SECONDS));\n\t\tfileRepository.save(file);\n\n\t\tvar expressionEqualsOk = new DateExpression(RelationalExpression.Operator.EQUALS, (int) file.getModified().getEpochSecond(), 0);\n\t\tvar expressionInRange = new DateExpression(RelationalExpression.Operator.IN_RANGE, (int) file.getModified().getEpochSecond() - 1, (int) file.getModified().getEpochSecond() + 1);\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName());\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionInRange)).getFirst().getName());\n\n\t\tfileRepository.delete(file);\n\t}\n\n\t@Test\n\tvoid Size()\n\t{\n\t\tvar file = FileFakes.createFile(\"foobar\", 1024);\n\t\tfileRepository.save(file);\n\n\t\tvar expressionEqualsOk = new SizeExpression(RelationalExpression.Operator.EQUALS, 1024, 0);\n\t\tvar expressionEqualsFail = new SizeExpression(RelationalExpression.Operator.EQUALS, 1025, 0);\n\n\t\tvar expressionGreaterThanOrEqualsOk = new SizeExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, 1024, 0);\n\t\tvar expressionGreaterThanOrEqualsFail = new SizeExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, 1023, 0);\n\n\t\tvar expressionGreaterThanOk = new SizeExpression(RelationalExpression.Operator.GREATER_THAN, 1025, 0);\n\t\tvar expressionGreaterThanFail = new SizeExpression(RelationalExpression.Operator.GREATER_THAN, 1024, 0);\n\n\t\tvar expressionLesserThanOrEqualsOk = new SizeExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, 1024, 0);\n\t\tvar expressionLesserThanOrEqualsFail = new SizeExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, 1025, 0);\n\n\t\tvar expressionLesserThanOk = new SizeExpression(RelationalExpression.Operator.LESSER_THAN, 1023, 0);\n\t\tvar expressionLesserThanFail = new SizeExpression(RelationalExpression.Operator.LESSER_THAN, 1024, 0);\n\n\t\tvar expressionInRangeOk = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1023, 1025);\n\t\tvar expressionInRangeFail = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1025, 1026);\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOrEqualsOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionGreaterThanOrEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionGreaterThanFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOrEqualsOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionLesserThanOrEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionLesserThanFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionInRangeOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionInRangeFail)).isEmpty());\n\n\t\tfileRepository.delete(file);\n\t}\n\n\t@Test\n\tvoid SizeMb()\n\t{\n\t\tvar file = FileFakes.createFile(\"foobar\", 1_000_000_000_000L);\n\t\tfileRepository.save(file);\n\n\t\tvar expressionEqualsOk = new SizeMbExpression(RelationalExpression.Operator.EQUALS, (int) (1_000_000_000_000L >> 20), 0);\n\t\tvar expressionEqualsFail = new SizeMbExpression(RelationalExpression.Operator.EQUALS, (int) (1_000_001_000_000L >> 20), 0);\n\n\t\tvar expressionGreaterThanOrEqualsOk = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, (int) (1_000_000_000_000L >> 20), 0);\n\t\tvar expressionGreaterThanOrEqualsFail = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, (int) (900_000_000_000L >> 20), 0);\n\n\t\tvar expressionGreaterThanOk = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN, (int) (1_000_001_000_000L >> 20), 0);\n\t\tvar expressionGreaterThanFail = new SizeMbExpression(RelationalExpression.Operator.GREATER_THAN, (int) (999_000_000_000L >> 20), 0); // Note that 1_000_000_000_000 should fail, but it doesn't because of the lost precision\n\n\t\tvar expressionLesserThanOrEqualsOk = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, (int) (1_000_000_000_000L >> 20), 0);\n\t\tvar expressionLesserThanOrEqualsFail = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, (int) (1_000_001_000_000L >> 20), 0);\n\n\t\tvar expressionLesserThanOk = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN, (int) (1_000_000_000_000L >> 20), 0);\n\t\tvar expressionLesserThanFail = new SizeMbExpression(RelationalExpression.Operator.LESSER_THAN, (int) (1_000_001_000_000L >> 20), 0);\n\n\t\tvar expressionInRangeOk = new SizeMbExpression(RelationalExpression.Operator.IN_RANGE, (int) (900_000_000_000L >> 20), (int) (1_000_001_000_000L >> 20));\n\t\tvar expressionInRangeFail = new SizeMbExpression(RelationalExpression.Operator.IN_RANGE, (int) (1_000_001_000_000L >> 20), (int) (2_000_000_000_000L >> 20));\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionEqualsOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOrEqualsOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionGreaterThanOrEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionGreaterThanOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionGreaterThanFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOrEqualsOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionLesserThanOrEqualsFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionLesserThanOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionLesserThanFail)).isEmpty());\n\n\t\tassertEquals(file.getName(), fileService.searchFiles(List.of(expressionInRangeOk)).getFirst().getName());\n\t\tassertTrue(fileService.searchFiles(List.of(expressionInRangeFail)).isEmpty());\n\n\t\tfileRepository.delete(file);\n\t}\n\n\t@Test\n\tvoid Popularity_NotSupported()\n\t{\n\t\tvar file = FileFakes.createFile(\"foobar\");\n\t\tfileRepository.save(file);\n\n\t\tvar expressionEqualsNotSupported = new PopularityExpression(RelationalExpression.Operator.EQUALS, 1, 0);\n\n\t\tassertTrue(fileService.searchFiles(List.of(expressionEqualsNotSupported)).isEmpty());\n\n\t\tfileRepository.delete(file);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/util/expression/ExpressionMapperTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.FileFakes;\nimport io.xeres.app.xrs.service.turtle.item.TurtleRegExpSearchRequestItem;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ExpressionMapperTest\n{\n\t@Test\n\tvoid Name()\n\t{\n\t\tList<Byte> tokens = new ArrayList<>();\n\t\tList<Integer> ints = new ArrayList<>();\n\t\tList<String> strings = new ArrayList<>();\n\n\t\ttokens.add((byte) 4); // Name\n\t\tints.add(1); // Contains all\n\t\tints.add(1); // Case-insensitive\n\t\tints.add(2); // 2 words\n\t\tstrings.add(\"foo\"); // word 1\n\t\tstrings.add(\"bar\"); // word 2\n\n\t\tvar item = new TurtleRegExpSearchRequestItem(tokens, ints, strings);\n\n\t\tvar expressions = ExpressionMapper.toExpressions(item);\n\t\tassertEquals(1, expressions.size());\n\t\tvar expression = expressions.getFirst();\n\t\tassertInstanceOf(NameExpression.class, expression);\n\t\tvar fileValid = FileFakes.createFile(\"foo bar\");\n\t\tvar fileInvalid = FileFakes.createFile(\"foo\");\n\t\tassertTrue(expression.evaluate(fileValid));\n\t\tassertFalse(expression.evaluate(fileInvalid));\n\t}\n\n\t@Test\n\tvoid Compound_NameAndSize()\n\t{\n\t\tList<Byte> tokens = new ArrayList<>();\n\t\tList<Integer> ints = new ArrayList<>();\n\t\tList<String> strings = new ArrayList<>();\n\n\t\ttokens.add((byte) 7); // Compound\n\t\tints.add(0); // And\n\n\t\ttokens.add((byte) 4); // Name\n\t\tints.add(2); // Equals\n\t\tints.add(1); // Case-insensitive\n\t\tints.add(1); // 1 word\n\t\tstrings.add(\"foo\"); // word 1\n\n\t\ttokens.add((byte) 2); // Size\n\t\tints.add(5); // In range\n\t\tints.add(1024); // Min value\n\t\tints.add(2048); // Max value\n\n\t\tvar item = new TurtleRegExpSearchRequestItem(tokens, ints, strings);\n\n\t\tvar expressions = ExpressionMapper.toExpressions(item);\n\t\tassertEquals(1, expressions.size());\n\t\tvar expression = expressions.getFirst();\n\t\tassertInstanceOf(CompoundExpression.class, expression);\n\t\tvar fileEntryValid = FileFakes.createFile(\"foo\", 1500);\n\t\tvar fileEntryInvalid1 = FileFakes.createFile(\"bar\", 1500);\n\t\tvar fileEntryInvalid2 = FileFakes.createFile(\"foo\", 3000);\n\t\tvar fileEntryInvalid3 = FileFakes.createFile(\"bar\", 3000);\n\t\tassertTrue(expression.evaluate(fileEntryValid));\n\t\tassertFalse(expression.evaluate(fileEntryInvalid1));\n\t\tassertFalse(expression.evaluate(fileEntryInvalid2));\n\t\tassertFalse(expression.evaluate(fileEntryInvalid3));\n\t}\n\n\t@Test\n\tvoid Linearize()\n\t{\n\t\tvar nameExpression = new NameExpression(StringExpression.Operator.EQUALS, \"foo\", false);\n\t\tvar sizeExpression = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1024, 2048);\n\t\tvar compoundExpression = new CompoundExpression(CompoundExpression.Operator.AND, nameExpression, sizeExpression);\n\n\t\tList<Byte> tokens = new ArrayList<>();\n\t\tList<Integer> ints = new ArrayList<>();\n\t\tList<String> strings = new ArrayList<>();\n\t\tcompoundExpression.linearize(tokens, ints, strings);\n\n\t\tassertEquals(3, tokens.size());\n\t\tassertEquals((byte) 7, tokens.getFirst()); // Compound\n\t\tassertEquals((byte) 4, tokens.get(1)); // Name\n\t\tassertEquals((byte) 2, tokens.get(2)); // Size\n\t\tassertEquals(7, ints.size());\n\t\tassertEquals(0, ints.getFirst()); // AND\n\t\tassertEquals(2, ints.get(1)); // Equals\n\t\tassertEquals(1, ints.get(2)); // Ignore case\n\t\tassertEquals(1, ints.get(3)); // 1 string\n\t\tassertEquals(5, ints.get(4)); // In range\n\t\tassertEquals(1024, ints.get(5)); // low value\n\t\tassertEquals(2048, ints.get(6)); // high value\n\t\tassertEquals(1, strings.size());\n\t\tassertEquals(\"foo\", strings.getFirst()); // 1 string\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/util/expression/ExpressionTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.util.expression;\n\nimport io.xeres.app.database.model.file.FileFakes;\nimport io.xeres.testutils.Sha1SumFakes;\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Instant;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass ExpressionTest\n{\n\t@Test\n\tvoid Name_Equals()\n\t{\n\t\tvar expression = new NameExpression(StringExpression.Operator.EQUALS, \"foobar\", false);\n\t\tvar fileCorrect = FileFakes.createFile(\"foobar\");\n\t\tvar fileWrong = FileFakes.createFile(\"blahblah\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Name_Equals_CaseSensitive()\n\t{\n\t\tvar expression = new NameExpression(StringExpression.Operator.EQUALS, \"foobar\", true);\n\t\tvar fileCorrect = FileFakes.createFile(\"foobar\");\n\t\tvar fileWrong = FileFakes.createFile(\"FooBar\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Name_ContainsAll()\n\t{\n\t\tvar expression = new NameExpression(StringExpression.Operator.CONTAINS_ALL, \"foo bar plop\", false);\n\t\tvar fileCorrect = FileFakes.createFile(\"foo bar plop\");\n\t\tvar fileWrong = FileFakes.createFile(\"foo bar\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Name_ContainsAll_CaseSensitive()\n\t{\n\t\tvar expression = new NameExpression(StringExpression.Operator.CONTAINS_ALL, \"foo bar plop\", true);\n\t\tvar fileCorrect = FileFakes.createFile(\"foo bar plop\");\n\t\tvar fileWrong = FileFakes.createFile(\"Foo bar plop\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Name_ContainsAny()\n\t{\n\t\tvar expression = new NameExpression(StringExpression.Operator.CONTAINS_ANY, \"foo bar plop\", false);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\");\n\t\tvar fileCorrect2 = FileFakes.createFile(\"bar\");\n\t\tvar fileCorrect3 = FileFakes.createFile(\"plop\");\n\t\tvar fileWrong = FileFakes.createFile(\"none niet nada\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertTrue(expression.evaluate(fileCorrect2));\n\t\tassertTrue(expression.evaluate(fileCorrect3));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Name_ContainsAny_CaseSensitive()\n\t{\n\t\tvar expression = new NameExpression(StringExpression.Operator.CONTAINS_ANY, \"foo bar plop\", true);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\");\n\t\tvar fileWrong1 = FileFakes.createFile(\"Bar\");\n\t\tvar fileWrong2 = FileFakes.createFile(\"Plop\");\n\t\tvar fileWrong3 = FileFakes.createFile(\"none niet nada\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertFalse(expression.evaluate(fileWrong1));\n\t\tassertFalse(expression.evaluate(fileWrong2));\n\t\tassertFalse(expression.evaluate(fileWrong3));\n\t}\n\n\t@Test\n\tvoid Size_Equals()\n\t{\n\t\tvar expression = new SizeExpression(RelationalExpression.Operator.EQUALS, 1024, 0);\n\t\tvar fileCorrect = FileFakes.createFile(\"foo\", 1024);\n\t\tvar fileWrong = FileFakes.createFile(\"foo\", 512);\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Size_GreaterThanOrEquals()\n\t{\n\t\tvar expression = new SizeExpression(RelationalExpression.Operator.GREATER_THAN_OR_EQUALS, 1024, 0);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\", 1024);\n\t\tvar fileCorrect2 = FileFakes.createFile(\"foo\", 1023);\n\t\tvar fileWrong = FileFakes.createFile(\"foo\", 1025);\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertTrue(expression.evaluate(fileCorrect2));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Size_GreaterThan()\n\t{\n\t\tvar expression = new SizeExpression(RelationalExpression.Operator.GREATER_THAN, 1024, 0);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\", 1023);\n\t\tvar fileWrong1 = FileFakes.createFile(\"foo\", 1024);\n\t\tvar fileWrong2 = FileFakes.createFile(\"foo\", 1025);\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertFalse(expression.evaluate(fileWrong1));\n\t\tassertFalse(expression.evaluate(fileWrong2));\n\t}\n\n\t@Test\n\tvoid Size_LesserThanOrEquals()\n\t{\n\t\tvar expression = new SizeExpression(RelationalExpression.Operator.LESSER_THAN_OR_EQUALS, 1024, 0);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\", 1024);\n\t\tvar fileCorrect2 = FileFakes.createFile(\"foo\", 1025);\n\t\tvar fileWrong = FileFakes.createFile(\"foo\", 512);\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertTrue(expression.evaluate(fileCorrect2));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Size_LesserThan()\n\t{\n\t\tvar expression = new SizeExpression(RelationalExpression.Operator.LESSER_THAN, 1024, 0);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\", 1025);\n\t\tvar fileWrong1 = FileFakes.createFile(\"foo\", 1024);\n\t\tvar fileWrong2 = FileFakes.createFile(\"foo\", 512);\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertFalse(expression.evaluate(fileWrong1));\n\t\tassertFalse(expression.evaluate(fileWrong2));\n\t}\n\n\t@Test\n\tvoid Size_InRange()\n\t{\n\t\tvar expression = new SizeExpression(RelationalExpression.Operator.IN_RANGE, 1024, 2048);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\", 1024);\n\t\tvar fileCorrect2 = FileFakes.createFile(\"foo\", 2048);\n\t\tvar fileCorrect3 = FileFakes.createFile(\"foo\", 1536);\n\t\tvar fileWrong1 = FileFakes.createFile(\"foo\", 1023);\n\t\tvar fileWrong2 = FileFakes.createFile(\"foo\", 2049);\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertTrue(expression.evaluate(fileCorrect2));\n\t\tassertTrue(expression.evaluate(fileCorrect3));\n\t\tassertFalse(expression.evaluate(fileWrong1));\n\t\tassertFalse(expression.evaluate(fileWrong2));\n\t}\n\n\t@Test\n\tvoid Date()\n\t{\n\t\tvar expression = new DateExpression(RelationalExpression.Operator.EQUALS, 1000, 0);\n\t\tvar fileCorrect = FileFakes.createFile(\"foo\", 1024, Instant.ofEpochSecond(1000));\n\t\tvar fileWrong = FileFakes.createFile(\"foo\", 1024, Instant.ofEpochSecond(2000));\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Popularity()\n\t{\n\t\t// Popularity is not implemented (there's no \"popularity\" in a local file), so it's always zero\n\t\tvar expression1 = new PopularityExpression(RelationalExpression.Operator.EQUALS, 1, 0);\n\t\tvar expression2 = new PopularityExpression(RelationalExpression.Operator.EQUALS, 0, 0);\n\t\tvar file = FileFakes.createFile(\"foo\");\n\n\t\tassertFalse(expression1.evaluate(file));\n\t\tassertTrue(expression2.evaluate(file));\n\t}\n\n\t@Test\n\tvoid SizeMb()\n\t{\n\t\tvar expression = new SizeMbExpression(RelationalExpression.Operator.EQUALS, (int) (1_000_000_000_000L >> 20), 0);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foo\", 1_000_000_000_000L);\n\t\tvar fileCorrect2 = FileFakes.createFile(\"foo\", 1_000_000_000_001L);\n\t\tvar fileWrong = FileFakes.createFile(\"foo\", 1_000_001_000_000L);\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertTrue(expression.evaluate(fileCorrect2));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Path()\n\t{\n\t\t// Path is not implemented because it's very difficult to do for no real gain\n\t\tvar expression = new PathExpression(StringExpression.Operator.CONTAINS_ANY, \"coolstuff\", false);\n\t\tvar file = FileFakes.createFile(\"foo\");\n\n\t\tassertFalse(expression.evaluate(file));\n\t}\n\n\t@Test\n\tvoid Extension()\n\t{\n\t\tvar expression = new ExtensionExpression(StringExpression.Operator.CONTAINS_ANY, \"exe com\", false);\n\t\tvar fileCorrect1 = FileFakes.createFile(\"foobar.exe\");\n\t\tvar fileCorrect2 = FileFakes.createFile(\"foobar.com\");\n\t\tvar fileWrong1 = FileFakes.createFile(\"foobar.bin\");\n\t\tvar fileWrong2 = FileFakes.createFile(\"The.Exe.bin\");\n\n\t\tassertTrue(expression.evaluate(fileCorrect1));\n\t\tassertTrue(expression.evaluate(fileCorrect2));\n\t\tassertFalse(expression.evaluate(fileWrong1));\n\t\tassertFalse(expression.evaluate(fileWrong2));\n\t}\n\n\t@Test\n\tvoid Hash()\n\t{\n\t\tvar hash1 = Sha1SumFakes.createSha1Sum();\n\t\tvar hash2 = Sha1SumFakes.createSha1Sum();\n\t\tvar expression = new HashExpression(StringExpression.Operator.EQUALS, hash1.toString());\n\t\tvar fileCorrect = FileFakes.createFile(\"foobar\", 0, null, hash1);\n\t\tvar fileWrong = FileFakes.createFile(\"foobar\", 0, null, hash2);\n\n\t\tassertTrue(expression.evaluate(fileCorrect));\n\t\tassertFalse(expression.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Compound_AND()\n\t{\n\t\tvar left = new NameExpression(StringExpression.Operator.EQUALS, \"foo\", false);\n\t\tvar right = new SizeExpression(RelationalExpression.Operator.EQUALS, 1000, 0);\n\t\tvar compound = new CompoundExpression(CompoundExpression.Operator.AND, left, right);\n\t\tvar fileCorrect = FileFakes.createFile(\"foo\", 1000);\n\t\tvar fileWrong = FileFakes.createFile(\"foo\", 1001);\n\n\t\tassertTrue(compound.evaluate(fileCorrect));\n\t\tassertFalse(compound.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Compound_OR()\n\t{\n\t\tvar left = new NameExpression(StringExpression.Operator.EQUALS, \"foo\", false);\n\t\tvar right = new SizeExpression(RelationalExpression.Operator.EQUALS, 1000, 0);\n\t\tvar compound = new CompoundExpression(CompoundExpression.Operator.OR, left, right);\n\t\tvar fileCorrectAnd = FileFakes.createFile(\"foo\", 1000);\n\t\tvar fileCorrectOr = FileFakes.createFile(\"foo\", 1001);\n\t\tvar fileWrong = FileFakes.createFile(\"bar\", 1001);\n\n\t\tassertTrue(compound.evaluate(fileCorrectAnd));\n\t\tassertTrue(compound.evaluate(fileCorrectOr));\n\t\tassertFalse(compound.evaluate(fileWrong));\n\t}\n\n\t@Test\n\tvoid Compound_XOR()\n\t{\n\t\tvar left = new NameExpression(StringExpression.Operator.EQUALS, \"foo\", false);\n\t\tvar right = new SizeExpression(RelationalExpression.Operator.EQUALS, 1000, 0);\n\t\tvar compound = new CompoundExpression(CompoundExpression.Operator.XOR, left, right);\n\t\tvar fileCorrectOr = FileFakes.createFile(\"foo\", 1001);\n\t\tvar fileWrongAnd = FileFakes.createFile(\"foo\", 1000);\n\t\tvar fileWrongBoth = FileFakes.createFile(\"bar\", 1001);\n\n\t\tassertTrue(compound.evaluate(fileCorrectOr));\n\t\tassertFalse(compound.evaluate(fileWrongAnd));\n\t\tassertFalse(compound.evaluate(fileWrongBoth));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/common/SecurityKeyTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.common;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.EnumSet;\nimport java.util.List;\n\nimport static io.xeres.app.xrs.common.SecurityKey.Flags.DISTRIBUTION_ADMIN;\nimport static io.xeres.app.xrs.common.SecurityKey.Flags.TYPE_PUBLIC_ONLY;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass SecurityKeyTest\n{\n\t@Test\n\tvoid CompareTo_Success()\n\t{\n\t\tvar securityKey1 = new SecurityKey(new GxsId(Id.toBytes(\"11111111111111111111111111111111\")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]);\n\t\tvar securityKey2 = new SecurityKey(new GxsId(Id.toBytes(\"22222222222222222222222222222222\")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]);\n\t\tvar securityKey3 = new SecurityKey(new GxsId(Id.toBytes(\"33333333333333333333333333333333\")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]);\n\t\tvar securityKey4 = new SecurityKey(new GxsId(Id.toBytes(\"44444444444444444444444444444444\")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]);\n\t\tvar securityKey5 = new SecurityKey(new GxsId(Id.toBytes(\"55555555555555555555555555555555\")), EnumSet.of(TYPE_PUBLIC_ONLY, DISTRIBUTION_ADMIN), 0, 1, new byte[1]);\n\n\t\tvar unorderedList = List.of(securityKey3, securityKey1, securityKey4, securityKey2, securityKey5);\n\n\t\tvar orderedList = unorderedList.stream()\n\t\t\t\t.sorted()\n\t\t\t\t.toList();\n\n\t\tassertEquals(securityKey1, orderedList.get(0));\n\t\tassertEquals(securityKey2, orderedList.get(1));\n\t\tassertEquals(securityKey3, orderedList.get(2));\n\t\tassertEquals(securityKey4, orderedList.get(3));\n\t\tassertEquals(securityKey5, orderedList.get(4));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/item/ItemHeaderTest.java",
    "content": "package io.xeres.app.xrs.item;\n\nimport io.netty.buffer.Unpooled;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass ItemHeaderTest\n{\n\t@Test\n\tvoid ReadHeader_Success()\n\t{\n\t\tvar buf = Unpooled.wrappedBuffer(new byte[]{2, 8, 8, 3, 0, 0, 0, 1});\n\n\t\tassertDoesNotThrow(() -> ItemHeader.readHeader(buf, 0x808, 3));\n\t}\n\n\t@Test\n\tvoid ReadHeader_WrongVersion()\n\t{\n\t\tvar buf = Unpooled.wrappedBuffer(new byte[]{1, 8, 8, 3, 0, 0, 0, 1});\n\n\t\tassertThrows(IllegalArgumentException.class,\n\t\t\t\t() -> ItemHeader.readHeader(buf, 0x808, 3),\n\t\t\t\t\"Packet version is not 0x2\");\n\t}\n\n\t@Test\n\tvoid ReadHeader_WrongType()\n\t{\n\t\tvar buf = Unpooled.wrappedBuffer(new byte[]{2, 8, 8, 3, 0, 0, 0, 1});\n\n\t\tassertThrows(IllegalArgumentException.class,\n\t\t\t\t() -> ItemHeader.readHeader(buf, 0x807, 3),\n\t\t\t\t\"Packet type is not 2055\");\n\t}\n\n\t@Test\n\tvoid ReadHeader_WrongSubtype()\n\t{\n\t\tvar buf = Unpooled.wrappedBuffer(new byte[]{2, 8, 8, 3, 0, 0, 0, 1});\n\n\t\tassertThrows(IllegalArgumentException.class,\n\t\t\t\t() -> ItemHeader.readHeader(buf, 0x808, 4),\n\t\t\t\t\"Packet subtype is not 4\");\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/item/ItemPriorityTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.item.ItemPriority.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ItemPriorityTest\n{\n\t@Test\n\tvoid Enum_Value_Fixed()\n\t{\n\t\tassertEquals(2, BACKGROUND.getPriority());\n\t\tassertEquals(3, DEFAULT.getPriority());\n\t\tassertEquals(5, NORMAL.getPriority());\n\t\tassertEquals(6, HIGH.getPriority());\n\t\tassertEquals(7, INTERACTIVE.getPriority());\n\t\tassertEquals(8, IMPORTANT.getPriority());\n\t\tassertEquals(9, REALTIME.getPriority());\n\n\t\tassertEquals(7, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/item/ItemTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.item;\n\nimport io.xeres.app.xrs.service.chat.item.ChatRoomMessageItem;\nimport io.xeres.app.xrs.service.filetransfer.item.TurtleChunkCrcItem;\nimport io.xeres.app.xrs.service.filetransfer.item.TurtleFileDataItem;\nimport io.xeres.testutils.Sha1SumFakes;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ItemTest\n{\n\t@Test\n\tvoid Bounce_Clone()\n\t{\n\t\tvar bounce = new ChatRoomMessageItem(\"Test\");\n\t\tvar bounceClone = bounce.clone();\n\n\t\tassertEquals(bounce.getMessage(), bounceClone.getMessage());\n\t\tassertEquals(bounce.getFlags(), bounceClone.getFlags());\n\t}\n\n\t@Test\n\tvoid TurtleChunkCrcItem_Clone()\n\t{\n\t\tvar sha1Sum = Sha1SumFakes.createSha1Sum();\n\n\t\tvar crcItem = new TurtleChunkCrcItem(1, sha1Sum);\n\t\tvar crcClone = crcItem.clone();\n\n\t\tassertEquals(crcItem.getChunkNumber(), crcClone.getChunkNumber());\n\t\tassertArrayEquals(crcItem.getChecksum().getBytes(), crcClone.getChecksum().getBytes());\n\t}\n\n\t@Test\n\tvoid TurtleFileDataItem_Clone()\n\t{\n\t\tbyte[] data = {1, 2, 3};\n\n\t\tvar turtleFileDataItem = new TurtleFileDataItem(1, data);\n\t\tvar turtleFileDataItemClone = turtleFileDataItem.clone();\n\n\t\tassertEquals(turtleFileDataItem.getChunkOffset(), turtleFileDataItemClone.getChunkOffset());\n\t\tassertArrayEquals(turtleFileDataItem.getChunkData(), turtleFileDataItemClone.getChunkData());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/SerialAll.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.xeres.common.id.LocationIdentifier;\n\nimport java.math.BigInteger;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Map;\n\npublic class SerialAll\n{\n\t@RsSerialized\n\tprivate int intPrimitiveField;\n\n\t@RsSerialized\n\tprivate Integer integerField;\n\n\t@RsSerialized\n\tprivate short shortPrimitiveField;\n\n\t@RsSerialized\n\tprivate Short shortField;\n\n\t@RsSerialized\n\tprivate byte bytePrimitiveField;\n\n\t@RsSerialized\n\tprivate Byte byteField;\n\n\t@RsSerialized\n\tprivate long longPrimitiveField;\n\n\t@RsSerialized\n\tprivate Long longField;\n\n\t@RsSerialized\n\tprivate float floatPrimitiveField;\n\n\t@RsSerialized\n\tprivate Float floatField;\n\n\t@RsSerialized\n\tprivate double doublePrimitiveField;\n\n\t@RsSerialized\n\tprivate Double doubleField;\n\n\t@RsSerialized\n\tprivate boolean booleanPrimitiveField;\n\n\t@RsSerialized\n\tprivate Boolean booleanField;\n\n\t@RsSerialized\n\tprivate byte[] bytes;\n\n\t@RsSerialized\n\tprivate BigInteger bigInteger;\n\n\t@RsSerialized\n\tprivate LocationIdentifier locationIdentifier;\n\n\t@RsSerialized\n\tprivate List<String> stringList;\n\n\t@RsSerialized\n\tprivate Map<Integer, String> stringMap;\n\n\t@RsSerialized\n\tprivate SerialEnum serialEnum;\n\n\t@RsSerialized\n\tprivate EnumSet<SerialEnum> enumSet;\n\n\t@RsSerialized(fieldSize = FieldSize.SHORT)\n\tprivate EnumSet<SerialEnum> enumSetShort;\n\n\t@RsSerialized(fieldSize = FieldSize.BYTE)\n\tprivate EnumSet<SerialEnum> enumSetByte;\n\n\t@RsSerialized(tlvType = TlvType.STR_NAME)\n\tprivate String tlvName;\n\n\tpublic int getIntPrimitiveField()\n\t{\n\t\treturn intPrimitiveField;\n\t}\n\n\tpublic void setIntPrimitiveField(int intPrimitiveField)\n\t{\n\t\tthis.intPrimitiveField = intPrimitiveField;\n\t}\n\n\tpublic Integer getIntegerField()\n\t{\n\t\treturn integerField;\n\t}\n\n\tpublic void setIntegerField(Integer integerField)\n\t{\n\t\tthis.integerField = integerField;\n\t}\n\n\tpublic short getShortPrimitiveField()\n\t{\n\t\treturn shortPrimitiveField;\n\t}\n\n\tpublic void setShortPrimitiveField(short shortPrimitiveField)\n\t{\n\t\tthis.shortPrimitiveField = shortPrimitiveField;\n\t}\n\n\tpublic Short getShortField()\n\t{\n\t\treturn shortField;\n\t}\n\n\tpublic void setShortField(Short shortField)\n\t{\n\t\tthis.shortField = shortField;\n\t}\n\n\tpublic byte getBytePrimitiveField()\n\t{\n\t\treturn bytePrimitiveField;\n\t}\n\n\tpublic void setBytePrimitiveField(byte bytePrimitiveField)\n\t{\n\t\tthis.bytePrimitiveField = bytePrimitiveField;\n\t}\n\n\tpublic Byte getByteField()\n\t{\n\t\treturn byteField;\n\t}\n\n\tpublic void setByteField(Byte byteField)\n\t{\n\t\tthis.byteField = byteField;\n\t}\n\n\tpublic long getLongPrimitiveField()\n\t{\n\t\treturn longPrimitiveField;\n\t}\n\n\tpublic void setLongPrimitiveField(long longPrimitiveField)\n\t{\n\t\tthis.longPrimitiveField = longPrimitiveField;\n\t}\n\n\tpublic Long getLongField()\n\t{\n\t\treturn longField;\n\t}\n\n\tpublic void setLongField(Long longField)\n\t{\n\t\tthis.longField = longField;\n\t}\n\n\tpublic float getFloatPrimitiveField()\n\t{\n\t\treturn floatPrimitiveField;\n\t}\n\n\tpublic void setFloatPrimitiveField(float floatPrimitiveField)\n\t{\n\t\tthis.floatPrimitiveField = floatPrimitiveField;\n\t}\n\n\tpublic Float getFloatField()\n\t{\n\t\treturn floatField;\n\t}\n\n\tpublic void setFloatField(Float floatField)\n\t{\n\t\tthis.floatField = floatField;\n\t}\n\n\tpublic double getDoublePrimitiveField()\n\t{\n\t\treturn doublePrimitiveField;\n\t}\n\n\tpublic void setDoublePrimitiveField(double doublePrimitiveField)\n\t{\n\t\tthis.doublePrimitiveField = doublePrimitiveField;\n\t}\n\n\tpublic Double getDoubleField()\n\t{\n\t\treturn doubleField;\n\t}\n\n\tpublic void setDoubleField(Double doubleField)\n\t{\n\t\tthis.doubleField = doubleField;\n\t}\n\n\tpublic boolean isBooleanPrimitiveField()\n\t{\n\t\treturn booleanPrimitiveField;\n\t}\n\n\tpublic void setBooleanPrimitiveField(boolean booleanPrimitiveField)\n\t{\n\t\tthis.booleanPrimitiveField = booleanPrimitiveField;\n\t}\n\n\tpublic Boolean getBooleanField()\n\t{\n\t\treturn booleanField;\n\t}\n\n\tpublic void setBooleanField(Boolean booleanField)\n\t{\n\t\tthis.booleanField = booleanField;\n\t}\n\n\tpublic byte[] getBytes()\n\t{\n\t\treturn bytes;\n\t}\n\n\tpublic void setBytes(byte[] bytes)\n\t{\n\t\tthis.bytes = bytes;\n\t}\n\n\tpublic BigInteger getBigInteger()\n\t{\n\t\treturn bigInteger;\n\t}\n\n\tpublic void setBigInteger(BigInteger bigInteger)\n\t{\n\t\tthis.bigInteger = bigInteger;\n\t}\n\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic void setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\tpublic List<String> getStringList()\n\t{\n\t\treturn stringList;\n\t}\n\n\tpublic void setStringList(List<String> stringList)\n\t{\n\t\tthis.stringList = stringList;\n\t}\n\n\tpublic Map<Integer, String> getStringMap()\n\t{\n\t\treturn stringMap;\n\t}\n\n\tpublic void setStringMap(Map<Integer, String> stringMap)\n\t{\n\t\tthis.stringMap = stringMap;\n\t}\n\n\tpublic SerialEnum getSerialEnum()\n\t{\n\t\treturn serialEnum;\n\t}\n\n\tpublic void setSerialEnum(SerialEnum serialEnum)\n\t{\n\t\tthis.serialEnum = serialEnum;\n\t}\n\n\tpublic EnumSet<SerialEnum> getEnumSet()\n\t{\n\t\treturn enumSet;\n\t}\n\n\tpublic void setEnumSet(EnumSet<SerialEnum> enumSet)\n\t{\n\t\tthis.enumSet = enumSet;\n\t}\n\n\tpublic String getTlvName()\n\t{\n\t\treturn tlvName;\n\t}\n\n\tpublic void setTlvName(String tlvName)\n\t{\n\t\tthis.tlvName = tlvName;\n\t}\n\n\tpublic EnumSet<SerialEnum> getEnumSetShort()\n\t{\n\t\treturn enumSetShort;\n\t}\n\n\tpublic void setEnumSetShort(EnumSet<SerialEnum> enumSetShort)\n\t{\n\t\tthis.enumSetShort = enumSetShort;\n\t}\n\n\tpublic EnumSet<SerialEnum> getEnumSetByte()\n\t{\n\t\treturn enumSetByte;\n\t}\n\n\tpublic void setEnumSetByte(EnumSet<SerialEnum> enumSetByte)\n\t{\n\t\tthis.enumSetByte = enumSetByte;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/SerialEnum.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\npublic enum SerialEnum\n{\n\tONE,\n\tTWO,\n\tTHREE,\n\tFOUR\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/SerialList.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport java.util.List;\n\npublic class SerialList\n{\n\t@RsSerialized\n\tprivate List<String> list;\n\n\tpublic List<String> getList()\n\t{\n\t\treturn list;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/SerialMap.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport java.util.Map;\n\npublic class SerialMap\n{\n\t@RsSerialized\n\tprivate Map<Integer, String> map;\n\n\tpublic Map<Integer, String> getMap()\n\t{\n\t\treturn map;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/SerializerTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.database.model.gxs.ForumGroupItemFakes;\nimport io.xeres.app.database.model.gxs.ForumMessageItemFakes;\nimport io.xeres.app.database.model.gxs.IdentityGroupItemFakes;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.net.protocol.PeerAddress;\nimport io.xeres.app.xrs.common.Signature;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomUtils;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport java.math.BigInteger;\nimport java.util.*;\n\nimport static io.xeres.app.xrs.serialization.Serializer.TLV_HEADER_SIZE;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass SerializerTest\n{\n\t@ParameterizedTest\n\t@ValueSource(ints = {Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 5})\n\tvoid Serialize_Int(int input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(4, size);\n\t\tassertEquals(input, buf.getInt(0));\n\n\t\tvar result = Serializer.deserializeInt(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(shorts = {Short.MIN_VALUE, Short.MAX_VALUE, 0, 5})\n\tvoid Serialize_Short(short input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(2, size);\n\t\tassertEquals(input, buf.getShort(0));\n\n\t\tvar result = Serializer.deserializeShort(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(bytes = {Byte.MIN_VALUE, Byte.MAX_VALUE, 0, 5})\n\tvoid Serialize_Byte(byte input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(1, size);\n\t\tassertEquals(input, buf.getByte(0));\n\n\t\tvar result = Serializer.deserializeByte(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(longs = {Long.MIN_VALUE, Long.MAX_VALUE, 0L, 5L})\n\tvoid Serialize_Long(long input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(8, size);\n\t\tassertEquals(input, buf.getLong(0));\n\n\t\tvar result = Serializer.deserializeLong(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(floats = {Float.MIN_VALUE, Float.MAX_VALUE, 0f, 5f})\n\tvoid Serialize_Float(float input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(4, size);\n\t\tassertEquals(input, buf.getFloat(0));\n\n\t\tvar result = Serializer.deserializeFloat(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(doubles = {Double.MIN_VALUE, Double.MAX_VALUE, 0.0, 5.0})\n\tvoid Serialize_Double(double input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(8, size);\n\t\tassertEquals(input, buf.getDouble(0));\n\n\t\tvar result = Serializer.deserializeDouble(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(booleans = {true, false})\n\tvoid Serialize_Boolean(boolean input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(1, size);\n\t\tassertEquals(input, buf.getBoolean(0));\n\n\t\tvar result = Serializer.deserializeBoolean(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\"\", \"hello\", \"hello world\", \" \"})\n\tvoid Serialize_String(String input)\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tvar stringBytes = input.getBytes();\n\n\t\tassertEquals(stringBytes.length + 4, size);\n\t\tvar output = new byte[stringBytes.length];\n\t\tbuf.getBytes(4, output);\n\t\tassertArrayEquals(stringBytes, output);\n\n\t\tvar result = Serializer.deserializeString(buf);\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_String_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, (String) null);\n\t\tassertEquals(4, size);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_ByteArray()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = new byte[]{1, 2, 3};\n\n\t\tvar size = Serializer.serialize(buf, input);\n\n\t\tassertEquals(4 + input.length, size);\n\t\tvar output = new byte[input.length];\n\t\tbuf.getBytes(4, output);\n\t\tassertArrayEquals(input, output);\n\n\t\tvar result = Serializer.deserializeByteArray(buf);\n\t\tassertArrayEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_ByteArray_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, (byte[]) null);\n\t\tassertEquals(4, size);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Identifier()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = LocationFakes.createLocation().getLocationIdentifier();\n\n\t\tvar size = Serializer.serialize(buf, input, LocationIdentifier.class);\n\n\t\tassertEquals(input.getLength(), size);\n\t\tvar output = new byte[input.getLength()];\n\t\tbuf.getBytes(0, output);\n\t\tassertArrayEquals(input.getBytes(), output);\n\n\t\tvar result = (LocationIdentifier) Serializer.deserialize(buf, LocationIdentifier.class);\n\n\t\tassertEquals(input, result);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Identifier_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = IdentifierSerializer.serialize(buf, GxsId.class, null);\n\t\tassertEquals(GxsId.LENGTH, size);\n\n\t\tvar result = (GxsId) Serializer.deserialize(buf, GxsId.class);\n\t\tassertNull(result);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Identifier_Null_Dynamic()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tassertThrows(IllegalStateException.class, () -> IdentifierSerializer.serialize(buf, ProfileFingerprint.class, null));\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_List()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = List.of(\"hello\", \"dude\");\n\n\t\tvar size = Serializer.serialize(buf, input.getClass(), input, null);\n\n\t\tvar listObject = new SerialList();\n\t\tvar result = Serializer.deserializeAnnotatedFields(buf, listObject);\n\t\tassertTrue(result);\n\t\tassertEquals(input.size(), listObject.getList().size());\n\t\tassertArrayEquals(input.get(0).getBytes(), listObject.getList().get(0).getBytes());\n\t\tassertArrayEquals(input.get(1).getBytes(), listObject.getList().get(1).getBytes());\n\n\t\tassertEquals(4 + 4 + input.get(0).getBytes().length + 4 + input.get(1).getBytes().length, size);\n\t\tassertEquals(input.size(), buf.getInt(0));\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_List_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, List.class, null, null);\n\t\tassertEquals(4, size);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Map()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = Map.of(1, \"foo\", 2, \"barbaz\");\n\n\t\tvar size = Serializer.serialize(buf, input.getClass(), input, null);\n\n\t\tvar mapObject = new SerialMap();\n\t\tvar result = Serializer.deserializeAnnotatedFields(buf, mapObject);\n\t\tassertTrue(result);\n\t\tassertEquals(input.size(), mapObject.getMap().size());\n\t\tassertArrayEquals(input.get(1).getBytes(), mapObject.getMap().get(1).getBytes());\n\t\tassertArrayEquals(input.get(2).getBytes(), mapObject.getMap().get(2).getBytes());\n\n\t\tassertEquals(67, size);\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Map_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar size = Serializer.serialize(buf, Map.class, null, null);\n\t\tassertEquals(6, size);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Enum()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = SerialEnum.TWO;\n\n\t\tvar size = Serializer.serialize(buf, input);\n\t\tassertEquals(4, size);\n\t\tassertEquals(1, buf.getInt(0));\n\n\t\tvar result = Serializer.deserializeEnum(buf, SerialEnum.class);\n\t\tassertEquals(input, result);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_Enum_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tassertThrows(NullPointerException.class, () -> Serializer.serialize(buf, (Enum<?>) null));\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_EnumSet()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = EnumSet.of(SerialEnum.TWO, SerialEnum.FOUR);\n\n\t\tvar size = Serializer.serialize(buf, input, FieldSize.INTEGER);\n\t\tassertEquals(4, size);\n\t\tassertEquals(1 << 1 | 1 << 3, buf.getInt(0));\n\n\t\tvar result = Serializer.deserializeEnumSet(buf, SerialEnum.class, FieldSize.INTEGER);\n\t\tassertEquals(input, result);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_EnumSet_Null()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tassertThrows(NullPointerException.class, () -> Serializer.serialize(buf, (EnumSet<?>) null, FieldSize.INTEGER));\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvString()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = \"foobar\";\n\n\t\tvar size = Serializer.serialize(buf, TlvType.STR_NAME, input);\n\t\tassertEquals(6 + input.getBytes().length, size);\n\n\t\tvar result = Serializer.deserialize(buf, TlvType.STR_NAME);\n\t\tassertEquals(input, result);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvKeySignature()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar key = RandomUtils.insecure().randomBytes(30);\n\n\t\tvar input = new Signature(IdFakes.createGxsId(), key);\n\n\t\tvar size = Serializer.serialize(buf, TlvType.SIGNATURE, input);\n\t\tassertEquals(6 + 6 + 38 + key.length, size);\n\n\t\tvar result = (Signature) Serializer.deserialize(buf, TlvType.SIGNATURE);\n\t\tassertEquals(input.getGxsId(), result.getGxsId());\n\t\tassertArrayEquals(input.getData(), result.getData());\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvKeySignatureSet()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tSet<Signature> input = new HashSet<>();\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar signature = RandomUtils.insecure().randomBytes(20);\n\t\tvar keySignature = new Signature(Signature.Type.ADMIN, gxsId, signature);\n\t\tinput.add(keySignature);\n\n\t\tvar size = Serializer.serialize(buf, TlvType.SIGNATURE_SET, input);\n\t\tassertEquals(TLV_HEADER_SIZE + TLV_HEADER_SIZE + 4 + TLV_HEADER_SIZE + TLV_HEADER_SIZE + GxsId.LENGTH * 2 + TLV_HEADER_SIZE + signature.length, size);\n\n\t\t@SuppressWarnings(\"unchecked\") var result = (Set<Signature>) Serializer.deserialize(buf, TlvType.SIGNATURE_SET);\n\t\tassertEquals(input.stream().findFirst().orElseThrow().getGxsId(), result.stream().findFirst().orElseThrow().getGxsId());\n\t\tassertArrayEquals(input.stream().findFirst().orElseThrow().getData(), result.stream().findFirst().orElseThrow().getData());\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvImage()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar input = new byte[2];\n\n\t\tvar size = Serializer.serialize(buf, TlvType.IMAGE, input);\n\t\tassertEquals(6 + 6 + 4 + input.length, size);\n\n\t\tvar result = (byte[]) Serializer.deserialize(buf, TlvType.IMAGE);\n\t\tassertArrayEquals(input, result);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvImage_Empty_Array()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar input = new byte[0];\n\n\t\tvar size = Serializer.serialize(buf, TlvType.IMAGE, input);\n\t\tassertEquals(6 + 6 + 4 + input.length, size);\n\n\t\tvar result = (byte[]) Serializer.deserialize(buf, TlvType.IMAGE);\n\t\tassertArrayEquals(input, result);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvSet_GxsId()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar gxsId1 = IdFakes.createGxsId();\n\t\tvar gxsId2 = IdFakes.createGxsId();\n\t\tSet<GxsId> input = new HashSet<>();\n\t\tinput.add(gxsId1);\n\t\tinput.add(gxsId2);\n\n\t\tvar size = Serializer.serialize(buf, TlvType.SET_GXS_ID, input);\n\t\tassertEquals(TLV_HEADER_SIZE + GxsId.LENGTH * input.size(), size);\n\n\t\t@SuppressWarnings(\"unchecked\") var result = (Set<GxsId>) Serializer.deserialize(buf, TlvType.SET_GXS_ID);\n\t\tassertEquals(2, result.size());\n\t\tassertTrue(result.contains(gxsId1));\n\t\tassertTrue(result.contains(gxsId2));\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvSet_MsgId()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar msgId1 = new MsgId(RandomUtils.insecure().randomBytes(MsgId.LENGTH));\n\t\tvar msgId2 = new MsgId(RandomUtils.insecure().randomBytes(MsgId.LENGTH));\n\t\tSet<MsgId> input = new HashSet<>();\n\t\tinput.add(msgId1);\n\t\tinput.add(msgId2);\n\n\t\tvar size = Serializer.serialize(buf, TlvType.SET_GXS_MSG_ID, input);\n\t\tassertEquals(TLV_HEADER_SIZE + MsgId.LENGTH * input.size(), size);\n\n\t\t@SuppressWarnings(\"unchecked\") var result = (Set<MsgId>) Serializer.deserialize(buf, TlvType.SET_GXS_MSG_ID);\n\t\tassertEquals(2, result.size());\n\t\tassertTrue(result.contains(msgId1));\n\t\tassertTrue(result.contains(msgId2));\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_TlvAddress()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar peerAddress = PeerAddress.fromAddress(\"192.168.1.1:1234\");\n\n\t\tvar size = Serializer.serialize(buf, TlvType.ADDRESS, peerAddress);\n\t\tassertEquals(TLV_HEADER_SIZE * 2 + 6, size);\n\n\t\tvar result = (PeerAddress) Serializer.deserialize(buf, TlvType.ADDRESS);\n\t\tassertEquals(PeerAddress.Type.IPV4, result.getType());\n\t\tassertTrue(result.isValid());\n\t\tassertTrue(result.getAddress().isPresent());\n\t\tassertEquals(\"192.168.1.1:1234\", result.getAddress().get());\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_IdentityGroupItem()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar identityGroupItem = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar result = new GxsMetaAndDataResult();\n\n\t\tvar size = Serializer.serializeGxsMetaAndDataItem(buf, identityGroupItem, EnumSet.noneOf(SerializationFlags.class), result);\n\t\tassertEquals(194, size);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_ForumGroupItem()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar forumGroupItem = ForumGroupItemFakes.createForumGroupItem();\n\t\tvar result = new GxsMetaAndDataResult();\n\n\t\tvar size = Serializer.serializeGxsMetaAndDataItem(buf, forumGroupItem, EnumSet.noneOf(SerializationFlags.class), result);\n\t\tassertEquals(172, size);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_ForumMessageItem()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\t\tvar forumMessageItem = ForumMessageItemFakes.createForumMessageItem();\n\t\tvar result = new GxsMetaAndDataResult();\n\n\t\tvar size = Serializer.serializeGxsMetaAndDataItem(buf, forumMessageItem, EnumSet.noneOf(SerializationFlags.class), result);\n\t\tassertEquals(154, size);\n\n\t\tbuf.release();\n\t}\n\n\t@Test\n\tvoid Serialize_ComplexObject()\n\t{\n\t\tvar buf = Unpooled.buffer();\n\n\t\tvar input = new SerialAll();\n\n\t\tinput.setIntPrimitiveField(5);\n\t\tinput.setIntegerField(5);\n\n\t\tinput.setShortPrimitiveField((short) 8);\n\t\tinput.setShortField((short) 8);\n\n\t\tinput.setBytePrimitiveField((byte) 10);\n\t\tinput.setByteField((byte) 10);\n\n\t\tinput.setLongPrimitiveField(12L);\n\t\tinput.setLongField(12L);\n\n\t\tinput.setFloatPrimitiveField(14f);\n\t\tinput.setFloatField(14f);\n\n\t\tinput.setDoublePrimitiveField(16.0);\n\t\tinput.setDoubleField(16.0);\n\n\t\tinput.setBooleanPrimitiveField(true);\n\t\tinput.setBooleanField(true);\n\n\t\tinput.setBytes(new byte[]{1, 2, 3});\n\n\t\tinput.setBigInteger(new BigInteger(\"123456789\"));\n\n\t\tinput.setLocationIdentifier(LocationFakes.createLocation().getLocationIdentifier());\n\n\t\tinput.setStringList(List.of(\"foo\", \"bar\"));\n\n\t\tinput.setStringMap(Map.of(1, \"bleh\", 2, \"plop\"));\n\n\t\tinput.setSerialEnum(SerialEnum.THREE);\n\n\t\tinput.setEnumSet(EnumSet.of(SerialEnum.ONE, SerialEnum.TWO));\n\n\t\tinput.setEnumSetByte(EnumSet.of(SerialEnum.ONE, SerialEnum.TWO));\n\n\t\tinput.setEnumSetShort(EnumSet.of(SerialEnum.ONE, SerialEnum.TWO));\n\n\t\tinput.setTlvName(\"foobar\");\n\n\t\tvar size = Serializer.serialize(buf, input.getClass(), input, null);\n\t\tassertTrue(size > 0);\n\n\t\tvar result = (SerialAll) Serializer.deserialize(buf, SerialAll.class);\n\n\t\tassertEquals(input.getIntPrimitiveField(), result.getIntPrimitiveField());\n\t\tassertEquals(input.getIntegerField(), result.getIntegerField());\n\n\t\tassertEquals(input.getShortPrimitiveField(), result.getShortPrimitiveField());\n\t\tassertEquals(input.getShortField(), result.getShortField());\n\n\t\tassertEquals(input.getBytePrimitiveField(), result.getBytePrimitiveField());\n\t\tassertEquals(input.getByteField(), result.getByteField());\n\n\t\tassertEquals(input.getLongPrimitiveField(), result.getLongPrimitiveField());\n\t\tassertEquals(input.getLongField(), result.getLongField());\n\n\t\tassertEquals(input.getFloatPrimitiveField(), result.getFloatPrimitiveField());\n\t\tassertEquals(input.getFloatField(), result.getFloatField());\n\n\t\tassertEquals(input.getDoublePrimitiveField(), result.getDoublePrimitiveField());\n\t\tassertEquals(input.getDoubleField(), result.getDoubleField());\n\n\t\tassertEquals(input.isBooleanPrimitiveField(), result.isBooleanPrimitiveField());\n\t\tassertEquals(input.getBooleanField(), result.getBooleanField());\n\n\t\tassertArrayEquals(input.getBytes(), result.getBytes());\n\n\t\tassertEquals(input.getBigInteger(), result.getBigInteger());\n\n\t\tassertEquals(input.getLocationIdentifier().getLength(), result.getLocationIdentifier().getLength());\n\t\tassertArrayEquals(input.getLocationIdentifier().getBytes(), result.getLocationIdentifier().getBytes());\n\n\t\tassertEquals(input.getStringList().size(), result.getStringList().size());\n\t\tassertIterableEquals(input.getStringList(), result.getStringList());\n\n\t\tassertEquals(input.getStringMap().size(), result.getStringMap().size());\n\t\tassertEquals(input.getStringMap(), result.getStringMap());\n\n\t\tassertEquals(input.getSerialEnum(), result.getSerialEnum());\n\n\t\tassertEquals(input.getEnumSet(), result.getEnumSet());\n\n\t\tassertEquals(input.getEnumSetByte(), result.getEnumSetByte());\n\n\t\tassertEquals(input.getEnumSetShort(), result.getEnumSetShort());\n\n\t\tassertEquals(input.getTlvName(), result.getTlvName());\n\n\t\tbuf.release();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/TlvImageSerializerTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.serialization.TlvImageSerializer.ImageType.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass TlvImageSerializerTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, AUTO_DETECT.ordinal());\n\t\tassertEquals(1, PNG.ordinal());\n\t\tassertEquals(2, JPEG.ordinal());\n\n\t\tassertEquals(3, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/serialization/TlvUtilsTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.serialization;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass TlvUtilsTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(TlvUtils.class);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/RsServiceInitPriorityTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.RsServiceInitPriority.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass RsServiceInitPriorityTest\n{\n\t@Test\n\tvoid NoTimeOverlap_Success()\n\t{\n\t\tassertTrue(IMMEDIATE.getMaxTime() < HIGH.getMinTime());\n\t\tassertTrue(HIGH.getMaxTime() < NORMAL.getMinTime());\n\t\tassertTrue(NORMAL.getMaxTime() < LOW.getMinTime());\n\t\tassertEquals(0, OFF.getMinTime());\n\t\tassertEquals(0, OFF.getMaxTime());\n\t}\n\n\t@Test\n\tvoid MinMax_Success()\n\t{\n\t\tassertTrue(IMMEDIATE.getMinTime() <= IMMEDIATE.getMaxTime());\n\t\tassertTrue(HIGH.getMinTime() <= HIGH.getMaxTime());\n\t\tassertTrue(NORMAL.getMinTime() <= NORMAL.getMaxTime());\n\t\tassertTrue(LOW.getMinTime() <= LOW.getMaxTime());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/RsServiceRulesTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service;\n\nimport com.tngtech.archunit.core.domain.JavaClasses;\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;\n\n@AnalyzeClasses(packagesOf = RsService.class)\nclass RsServiceRulesTest\n{\n\t@ArchTest\n\tvoid rs_service_naming(JavaClasses classes)\n\t{\n\t\tclasses().that().areAssignableTo(RsService.class)\n\t\t\t\t.should().haveSimpleNameEndingWith(\"RsService\").check(classes);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/bandwidth/BandwidthUtilsTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.bandwidth;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass BandwidthUtilsTest\n{\n\t@Test\n\tvoid findBandwidthOnWindowsAnti()\n\t{\n\t\t// This one has an unplugged ethernet interface with a secondary connection\n\t\t// The correct USB Wi-Fi dongle interface\n\t\t// And an incorrect XBox adapter\n\t\tvar input = \"\"\"\n\t\t\t\t0\n\t\t\t\t0\n\t\t\t\t1300000000\n\t\t\t\t600000000\n\t\t\t\t\"\"\";\n\n\t\tassertEquals(1_300_000_000L, BandwidthUtils.searchBandwidthOnWindows(input));\n\t}\n\n\t@Test\n\tvoid findBandwidthOnWindowsZapek()\n\t{\n\t\t// Mine only has one default ethernet interface\n\t\tvar input = \"\"\"\n\t\t\t\t2500000000\n\t\t\t\t\"\"\";\n\n\t\tassertEquals(2_500_000_000L, BandwidthUtils.searchBandwidthOnWindows(input));\n\t}\n\n\t@Test\n\tvoid findBandwidthOnWindowsNotANumber()\n\t{\n\t\tvar input = \"\"\"\n\t\t\t\twoohoo\n\t\t\t\t\"\"\";\n\n\t\tassertEquals(0L, BandwidthUtils.searchBandwidthOnWindows(input));\n\t}\n\n\t@Test\n\tvoid findBandwidthOnLinux()\n\t{\n\t\tvar input = \"\"\"\n\t\t\t\t1000\n\t\t\t\t\"\"\";\n\n\t\tassertEquals(1_000_000_000L, BandwidthUtils.searchBandwidthOnLinux(input));\n\t}\n\n\t@Test\n\tvoid findBandwidthOnMac()\n\t{\n\t\tvar input = \"\"\"\n\t\t\t\ten0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n\t\t\t\t\toptions=40b<RXCSUM,TXCSUM,VLAN_HWTAGGING,CHANNEL_IO>\n\t\t\t\t\tether 00:0c:29:da:8c:2a\\s\n\t\t\t\t\tinet6 fe80::184a:e26f:63c5:df33%en0 prefixlen 64 secured scopeid 0x4\\s\n\t\t\t\t\tinet 192.168.136.128 netmask 0xffffff00 broadcast 192.168.136.255\n\t\t\t\t\tnd6 options=201<PERFORMNUD,DAD>\n\t\t\t\t\tmedia: autoselect (1000baseT <full-duplex>)\n\t\t\t\t\tstatus: active\n\t\t\t\t\"\"\";\n\n\t\tassertEquals(1_000_000_000L, BandwidthUtils.searchBandwidthOnMac(input));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/chat/ChatFlagsTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.chat.ChatFlags.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChatFlagsTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, PRIVATE.ordinal());\n\t\tassertEquals(1, REQUEST_AVATAR.ordinal());\n\t\tassertEquals(2, CONTAINS_AVATAR.ordinal());\n\t\tassertEquals(3, AVATAR_AVAILABLE.ordinal());\n\t\tassertEquals(4, CUSTOM_STATE.ordinal());\n\t\tassertEquals(5, PUBLIC.ordinal());\n\t\tassertEquals(6, REQUEST_CUSTOM_STATE.ordinal());\n\t\tassertEquals(7, CUSTOM_STATE_AVAILABLE.ordinal());\n\t\tassertEquals(8, PARTIAL_MESSAGE.ordinal());\n\t\tassertEquals(9, LOBBY.ordinal());\n\t\tassertEquals(10, CLOSING_DISTANT_CONNECTION.ordinal());\n\t\tassertEquals(11, ACK_DISTANT_CONNECTION.ordinal());\n\t\tassertEquals(12, KEEP_ALIVE.ordinal());\n\t\tassertEquals(13, CONNECTION_REFUSED.ordinal());\n\n\t\tassertEquals(14, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/chat/ChatRoomEventTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.chat.item.ChatRoomEvent.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChatRoomEventTest\n{\n\t@Test\n\tvoid Enum_Values()\n\t{\n\t\tassertEquals(1, PEER_LEFT.getCode());\n\t\tassertEquals(2, PEER_STATUS.getCode());\n\t\tassertEquals(3, PEER_JOINED.getCode());\n\t\tassertEquals(4, PEER_CHANGE_NICKNAME.getCode());\n\t\tassertEquals(5, KEEP_ALIVE.getCode());\n\n\t\tassertEquals(5, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/chat/ChatRoomServiceTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.database.model.chat.ChatRoom;\nimport io.xeres.app.database.model.chat.ChatRoomFakes;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.database.repository.ChatRoomRepository;\nimport io.xeres.common.message.chat.RoomType;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass ChatRoomServiceTest\n{\n\t@Mock\n\tprivate ChatRoomRepository chatRoomRepository;\n\n\t@InjectMocks\n\tprivate ChatRoomService chatRoomService;\n\n\t@Test\n\tvoid CreateChatRoom_Success()\n\t{\n\t\tchatRoomService.createChatRoom(createSignedChatRoom(), IdentityFakes.createOwn());\n\t\tverify(chatRoomRepository).save(any(ChatRoom.class));\n\t}\n\n\t@Test\n\tvoid SubscribeToChatRoomAndJoin_Success()\n\t{\n\t\tvar serviceChatRoom = createSignedChatRoom();\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar chatRoom = ChatRoomFakes.createChatRoomEntity(serviceChatRoom.getId(), identity, serviceChatRoom.getName(), serviceChatRoom.getTopic(), 0);\n\n\t\twhen(chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity)).thenReturn(Optional.of(chatRoom));\n\n\t\tvar subscribedChatRoom = chatRoomService.subscribeToChatRoomAndJoin(serviceChatRoom, identity);\n\n\t\tassertTrue(subscribedChatRoom.isSubscribed());\n\t\tassertTrue(subscribedChatRoom.isJoined());\n\n\t\tverify(chatRoomRepository).findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity);\n\t}\n\n\t@Test\n\tvoid UnsubscribeFromChatRoomAndLeave_Success()\n\t{\n\t\tvar serviceChatRoom = createSignedChatRoom();\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar chatRoom = ChatRoomFakes.createChatRoomEntity(serviceChatRoom.getId(), identity, serviceChatRoom.getName(), serviceChatRoom.getTopic(), 0);\n\n\t\twhen(chatRoomRepository.findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity)).thenReturn(Optional.of(chatRoom));\n\n\t\tvar unsubscribedChatRoom = chatRoomService.unsubscribeFromChatRoomAndLeave(serviceChatRoom.getId(), identity);\n\n\t\tassertFalse(unsubscribedChatRoom.isSubscribed());\n\t\tassertFalse(unsubscribedChatRoom.isJoined());\n\n\t\tverify(chatRoomRepository).findByRoomIdAndIdentityGroupItem(chatRoom.getRoomId(), identity);\n\t}\n\n\tprivate io.xeres.app.xrs.service.chat.ChatRoom createSignedChatRoom()\n\t{\n\t\treturn new io.xeres.app.xrs.service.chat.ChatRoom(1L, \"test\", \"something\", RoomType.PUBLIC, 1, true);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/chat/ChatRsServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.MessageService;\nimport io.xeres.app.service.UnHtmlService;\nimport io.xeres.app.service.script.ScriptService;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.chat.item.ChatMessageItem;\nimport io.xeres.app.xrs.service.chat.item.ChatRoomListItem;\nimport io.xeres.app.xrs.service.chat.item.ChatRoomListRequestItem;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.ChatMessage;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.EnumSet;\n\nimport static io.xeres.common.message.MessagePath.chatPrivateDestination;\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.ArgumentMatchers.*;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@SuppressWarnings(\"unused\")\n@ExtendWith(MockitoExtension.class)\nclass ChatRsServiceTest\n{\n\t@Mock\n\tprivate PeerConnectionManager peerConnectionManager;\n\n\t@Mock\n\tprivate DatabaseSessionManager databaseSessionManager;\n\n\t@Mock\n\tprivate IdentityService identityService;\n\n\t@Mock\n\tprivate ChatRoomService chatRoomService;\n\n\t@Mock\n\tprivate ChatBacklogService chatBacklogService;\n\n\t@Mock\n\tprivate MessageService messageService;\n\n\t@Mock\n\tprivate UnHtmlService unHtmlService;\n\n\t@Mock\n\tprivate ScriptService scriptService;\n\n\t@InjectMocks\n\tprivate ChatRsService chatRsService;\n\n\t@Test\n\tvoid HandleChatMessageItem_Success()\n\t{\n\t\tvar message = \"hello\";\n\t\tvar peerConnection = new PeerConnection(LocationFakes.createLocation(), null);\n\n\t\tvar item = new ChatMessageItem(message, EnumSet.of(ChatFlags.PRIVATE));\n\n\t\twhen(unHtmlService.cleanupMessage(anyString())).thenAnswer(invocation -> invocation.getArgument(0));\n\n\t\tchatRsService.handleItem(peerConnection, item);\n\n\t\tverify(messageService).sendToConsumers(eq(chatPrivateDestination()), eq(MessageType.CHAT_PRIVATE_MESSAGE), eq(peerConnection.getLocation().getLocationIdentifier()), argThat(chatMessage -> {\n\t\t\tassertNotNull(chatMessage);\n\t\t\tassertEquals(message, ((ChatMessage) (chatMessage)).getContent());\n\t\t\treturn true;\n\t\t}));\n\t}\n\n\t@Test\n\tvoid HandleChatMessageItem_Partial_Success()\n\t{\n\t\tvar message1 = \"hello, \";\n\t\tvar message2 = \"world\";\n\t\tvar peerConnection = new PeerConnection(LocationFakes.createLocation(), null);\n\n\t\tvar item1 = new ChatMessageItem(message1, EnumSet.of(ChatFlags.PRIVATE, ChatFlags.PARTIAL_MESSAGE));\n\t\tvar item2 = new ChatMessageItem(message2, EnumSet.of(ChatFlags.PRIVATE));\n\n\t\twhen(unHtmlService.cleanupMessage(anyString())).thenAnswer(invocation -> invocation.getArgument(0));\n\n\t\tchatRsService.handleItem(peerConnection, item1);\n\t\tchatRsService.handleItem(peerConnection, item2);\n\n\t\tverify(messageService).sendToConsumers(eq(chatPrivateDestination()), eq(MessageType.CHAT_PRIVATE_MESSAGE), eq(peerConnection.getLocation().getLocationIdentifier()), argThat(chatMessage -> {\n\t\t\tassertNotNull(chatMessage);\n\t\t\tassertEquals(message1 + message2, ((ChatMessage) (chatMessage)).getContent());\n\t\t\treturn true;\n\t\t}));\n\t}\n\n\t@Test\n\tvoid HandleChatRoomListRequestItem_Empty_Success()\n\t{\n\t\tvar peerConnection = new PeerConnection(LocationFakes.createLocation(), null);\n\n\t\tvar item = new ChatRoomListRequestItem();\n\n\t\tchatRsService.handleItem(peerConnection, item);\n\n\t\tverify(peerConnectionManager).writeItem(eq(peerConnection), argThat(chatRoomListItem -> {\n\t\t\tassertNotNull(chatRoomListItem);\n\t\t\tassertTrue(((ChatRoomListItem) chatRoomListItem).getChatRooms().isEmpty());\n\t\t\treturn true;\n\t\t}), any(RsService.class));\n\t}\n\n\t@Test\n\tvoid HandleChatRoomListRequestItem_Success()\n\t{\n\t\tvar roomName = \"test\";\n\t\tvar roomTopic = \"test topic\";\n\t\tvar roomFlags = EnumSet.of(RoomFlags.PUBLIC);\n\n\t\tvar ownIdentity = IdentityFakes.createOwn();\n\n\t\tvar peerConnection = new PeerConnection(LocationFakes.createLocation(), null);\n\n\t\tvar item = new ChatRoomListRequestItem();\n\n\t\twhen(identityService.getOwnIdentity()).thenReturn(ownIdentity);\n\n\t\tvar roomId = chatRsService.createChatRoom(roomName, roomTopic, roomFlags, false);\n\t\tchatRsService.handleItem(peerConnection, item);\n\n\t\tverify(peerConnectionManager).writeItem(eq(peerConnection), argThat(chatRoomListItem -> {\n\t\t\tassertNotNull(chatRoomListItem);\n\t\t\tassertFalse(((ChatRoomListItem) chatRoomListItem).getChatRooms().isEmpty());\n\t\t\tassertEquals(roomId, ((ChatRoomListItem) chatRoomListItem).getChatRooms().getFirst().getId());\n\t\t\treturn true;\n\t\t}), any(RsService.class));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/chat/RoomFlagsTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.chat;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.chat.RoomFlags.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass RoomFlagsTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, AUTO_SUBSCRIBE.ordinal());\n\t\tassertEquals(1, UNUSED.ordinal());\n\t\tassertEquals(2, PUBLIC.ordinal());\n\t\tassertEquals(3, CHALLENGE.ordinal());\n\t\tassertEquals(4, PGP_SIGNED.ordinal());\n\n\t\tassertEquals(5, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/discovery/DiscoveryPgpListItemTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.discovery.item.DiscoveryPgpListItem.Mode.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass DiscoveryPgpListItemTest\n{\n\t@Test\n\tvoid Mode_Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, NONE.ordinal());\n\t\tassertEquals(1, FRIENDS.ordinal());\n\t\tassertEquals(2, GET_CERT.ordinal());\n\n\t\tassertEquals(3, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/discovery/DiscoveryRsServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.discovery;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.notification.status.StatusNotificationService;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.discovery.item.DiscoveryContactItem;\nimport io.xeres.app.xrs.service.discovery.item.DiscoveryPgpListItem;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.protocol.NetMode;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.List;\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass DiscoveryRsServiceTest\n{\n\t@Mock\n\tprivate PeerConnectionManager peerConnectionManager;\n\n\t@Mock\n\tprivate ProfileService profileService;\n\n\t@Mock\n\tprivate LocationService locationService;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate StatusNotificationService statusNotificationService;\n\n\t@InjectMocks\n\tprivate DiscoveryRsService discoveryRsService;\n\n\t/**\n\t * This is a case that is handled by RS but that I think is never actually sent.\n\t * We ignore it, just in case.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_NewLocation_FriendOfFriend_Known_Ignore()\n\t{\n\t\tvar peerConnection = new PeerConnection(LocationFakes.createLocation(), null);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.empty());\n\t\twhen(profileService.findProfileByPgpIdentifier(anyLong())).thenReturn(Optional.empty());\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(LocationFakes.createLocation()));\n\n\t\tverify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class));\n\t}\n\n\t/**\n\t * This is a case that shouldn't happen either.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_NewLocation_FriendOfFriend_Unknown_Ignore()\n\t{\n\t\tvar peerConnection = new PeerConnection(LocationFakes.createLocation(), null);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(any(LocationIdentifier.class))).thenReturn(Optional.empty());\n\t\twhen(profileService.findProfileByPgpIdentifier(anyLong())).thenReturn(Optional.of(ProfileFakes.createProfile()));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(LocationFakes.createLocation()));\n\n\t\tverify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class));\n\t}\n\n\t/**\n\t * The peer sends the new location of a common friend. We keep that new location.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_NewLocation_Friend_Success()\n\t{\n\t\tvar peerLocation = LocationFakes.createLocation();\n\t\tvar peerConnection = new PeerConnection(peerLocation, null);\n\t\tvar profile = ProfileFakes.createProfile();\n\t\tprofile.setAccepted(true);\n\t\tvar newLocation = LocationFakes.createLocation(\"foo\", profile);\n\n\t\twhen(profileService.findProfileByPgpIdentifier(profile.getPgpIdentifier())).thenReturn(Optional.of(profile));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(newLocation));\n\n\t\tverify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class));\n\t\tverify(locationService).update(eq(newLocation), anyString(), any(NetMode.class), anyString(), any(), anyList());\n\t}\n\n\t/**\n\t * The peer sends an updated location of a common friend. We update\n\t * the location.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_UpdateLocation_Friend_Success()\n\t{\n\t\tvar peerLocation = LocationFakes.createLocation();\n\t\tvar peerConnection = new PeerConnection(peerLocation, null);\n\t\tvar profile = ProfileFakes.createProfile();\n\t\tprofile.setAccepted(true);\n\t\tvar friendLocation = LocationFakes.createLocation(\"foo\", profile);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(friendLocation.getLocationIdentifier())).thenReturn(Optional.of(friendLocation));\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(LocationFakes.createOwnLocation()));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(friendLocation));\n\n\t\tverify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class));\n\t\tverify(locationService).update(eq(friendLocation), anyString(), any(NetMode.class), anyString(), any(), anyList());\n\t}\n\n\t/**\n\t * The peer sends our own location. We do nothing (could be used to help find out our external\n\t * IP address).\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_UpdateLocation_Own_Ignore()\n\t{\n\t\tvar peerLocation = LocationFakes.createLocation();\n\t\tvar peerConnection = new PeerConnection(peerLocation, null);\n\t\tvar profile = ProfileFakes.createProfile();\n\t\tprofile.setAccepted(true);\n\t\tvar friendLocation = LocationFakes.createLocation(\"foo\", profile);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(friendLocation.getLocationIdentifier())).thenReturn(Optional.of(friendLocation));\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(friendLocation));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(friendLocation));\n\n\t\tverify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class));\n\t}\n\n\t/**\n\t * The peer sends his location. We update its location and send our list\n\t * of friends.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_UpdateLocation_Peer_Success()\n\t{\n\t\tvar peerLocation = LocationFakes.createLocation();\n\t\tvar peerConnection = new PeerConnection(peerLocation, null);\n\t\tvar ownLocation = LocationFakes.createLocation();\n\t\tvar profile = ProfileFakes.createProfile();\n\t\tprofile.setAccepted(true);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(peerLocation.getLocationIdentifier())).thenReturn(Optional.of(peerLocation));\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation));\n\t\twhen(profileService.getAllDiscoverableProfiles()).thenReturn(List.of(profile));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(peerLocation));\n\n\t\tverify(locationService).update(eq(peerLocation), anyString(), any(NetMode.class), anyString(), any(), anyList());\n\t\tvar discoveryPgpListItem = ArgumentCaptor.forClass(DiscoveryPgpListItem.class);\n\t\tverify(peerConnectionManager).writeItem(eq(peerConnection), discoveryPgpListItem.capture(), any(RsService.class));\n\n\t\tassertEquals(DiscoveryPgpListItem.Mode.FRIENDS, discoveryPgpListItem.getValue().getMode());\n\t\tassertTrue(discoveryPgpListItem.getValue().getPgpIds().contains(profile.getPgpIdentifier()));\n\t}\n\n\t/**\n\t * The peer sends his location. We update its location but don't send our list of\n\t * friends because we're not discoverable.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_UpdateLocation_Peer_OurLocation_NotDiscoverable_Success()\n\t{\n\t\tvar peerLocation = LocationFakes.createLocation();\n\t\tvar peerConnection = new PeerConnection(peerLocation, null);\n\t\tvar ownLocation = LocationFakes.createLocation();\n\t\townLocation.setDiscoverable(false);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(peerLocation.getLocationIdentifier())).thenReturn(Optional.of(peerLocation));\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(peerLocation));\n\n\t\tverify(locationService).findLocationByLocationIdentifier(peerLocation.getLocationIdentifier());\n\t\tverify(locationService).findOwnLocation();\n\t\tverify(locationService).update(eq(peerLocation), anyString(), any(NetMode.class), anyString(), any(), anyList());\n\t\tverify(peerConnectionManager, never()).writeItem(eq(peerConnection), any(Item.class), any(RsService.class));\n\t}\n\n\t/**\n\t * The peer sends his location. We update its location and since it's a partial profile (added through\n\t * ShortInvites) we ask for its PGP key.\n\t */\n\t@Test\n\tvoid HandleDiscoveryContactItem_UpdateLocation_Peer_Partial_Success()\n\t{\n\t\tvar peerLocation = LocationFakes.createLocation();\n\t\tvar peerConnection = new PeerConnection(peerLocation, null);\n\t\tvar peerProfile = ProfileFakes.createProfile();\n\t\tpeerProfile.setAccepted(true);\n\t\tpeerProfile.setPgpPublicKeyData(null); // partial profile\n\t\tpeerLocation.setProfile(peerProfile);\n\n\t\twhen(locationService.findLocationByLocationIdentifier(peerLocation.getLocationIdentifier())).thenReturn(Optional.of(peerLocation));\n\n\t\tdiscoveryRsService.handleItem(peerConnection, createDiscoveryContact(peerLocation));\n\n\t\tverify(locationService).update(eq(peerLocation), anyString(), any(NetMode.class), anyString(), any(), anyList());\n\t\tvar discoveryPgpListItem = ArgumentCaptor.forClass(DiscoveryPgpListItem.class);\n\t\tverify(peerConnectionManager).writeItem(eq(peerConnection), discoveryPgpListItem.capture(), any(RsService.class));\n\n\t\tassertEquals(DiscoveryPgpListItem.Mode.GET_CERT, discoveryPgpListItem.getValue().getMode());\n\t\tassertTrue(discoveryPgpListItem.getValue().getPgpIds().contains(peerProfile.getPgpIdentifier()));\n\t}\n\n\tprivate DiscoveryContactItem createDiscoveryContact(Location location)\n\t{\n\t\tvar builder = DiscoveryContactItem.builder();\n\n\t\tbuilder.setPgpIdentifier(location.getProfile().getPgpIdentifier());\n\t\tbuilder.setLocationIdentifier(location.getLocationIdentifier());\n\t\tbuilder.setLocationName(location.getName());\n\t\tbuilder.setHostname(\"foobar.com\"); // XXX: no hostname support in location yet\n\t\tbuilder.setNetMode(location.getNetMode());\n\t\tbuilder.setVersion(location.getVersion());\n\t\treturn builder.build();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/filetransfer/ChunkDistributorTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.BitSet;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.LINEAR;\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.RANDOM;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChunkDistributorTest\n{\n\t@Test\n\tvoid Linear_Given()\n\t{\n\t\tvar availableChunkMap = new BitSet(4);\n\t\tavailableChunkMap.set(0, 4);\n\t\tvar chunkMap = new BitSet(4);\n\t\tvar chunkDistributor = new ChunkDistributor(chunkMap, 4, LINEAR);\n\n\t\tassertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(2, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(3, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t}\n\n\t@Test\n\tvoid Linear_GivenAndUsed()\n\t{\n\t\tvar availableChunkMap = new BitSet(4);\n\t\tavailableChunkMap.set(0, 4);\n\t\tvar chunkMap = new BitSet(4);\n\t\tvar chunkDistributor = new ChunkDistributor(chunkMap, 4, LINEAR);\n\n\t\tassertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tchunkMap.set(0);\n\t\tassertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tchunkMap.set(1);\n\t\tchunkMap.set(2);\n\t\tassertEquals(3, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tchunkMap.set(3);\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t}\n\n\t@Test\n\tvoid GivenAndUsed2()\n\t{\n\t\tvar availableChunkMap = new BitSet(8);\n\t\tavailableChunkMap.set(0, 8);\n\t\tvar chunkMap = new BitSet(8);\n\t\tvar chunkDistributor = new ChunkDistributor(chunkMap, 8, LINEAR);\n\n\t\tassertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tchunkMap.set(0);\n\t\tassertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(2, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(3, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(4, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(5, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tchunkMap.set(1);\n\t\tchunkMap.set(2);\n\t\tchunkMap.set(3);\n\t\tchunkMap.set(4);\n\t\tassertEquals(6, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tchunkMap.set(5);\n\t\tchunkMap.set(6);\n\t\tassertEquals(7, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t}\n\n\t@Test\n\tvoid Random_Given()\n\t{\n\t\tvar availableChunkMap = new BitSet(4);\n\t\tavailableChunkMap.set(0, 4);\n\t\tvar chunkMap = new BitSet(4);\n\t\tvar chunkDistributor = new ChunkDistributor(chunkMap, 4, RANDOM);\n\n\t\tvar chunk1 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow();\n\t\tvar chunk2 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow();\n\t\tvar chunk3 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow();\n\t\tvar chunk4 = chunkDistributor.getNextChunk(availableChunkMap).orElseThrow();\n\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t\tvar all = Set.of(chunk1, chunk2, chunk3, chunk4);\n\t\tassertEquals(4, all.size());\n\t}\n\n\t@Test\n\tvoid Linear_Given_NotAllAvailable()\n\t{\n\t\tvar availableChunkMap = new BitSet(4);\n\t\tavailableChunkMap.set(0, 2);\n\t\tvar chunkMap = new BitSet(4);\n\t\tvar chunkDistributor = new ChunkDistributor(chunkMap, 4, LINEAR);\n\n\t\tassertEquals(0, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(1, chunkDistributor.getNextChunk(availableChunkMap).orElseThrow());\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t\tassertEquals(Optional.empty(), chunkDistributor.getNextChunk(availableChunkMap));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/filetransfer/ChunkMapUtilsTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.BitSet;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChunkMapUtilsTest\n{\n\t@Test\n\tvoid Instance_ThrowException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ChunkMapUtils.class);\n\t}\n\n\t@Test\n\tvoid ToCompressedChunkMap()\n\t{\n\t\tvar input = new BitSet(4);\n\t\tinput.set(0);\n\t\tinput.set(1);\n\t\tinput.set(31);\n\t\tinput.set(32);\n\t\tinput.set(33);\n\t\tinput.set(64);\n\n\t\tvar output = ChunkMapUtils.toCompressedChunkMap(input);\n\n\t\tassertEquals(-2147483645, output.getFirst());\n\t\tassertEquals(3, output.get(1));\n\t\tassertEquals(1, output.get(2));\n\t}\n\n\t@Test\n\tvoid Transform()\n\t{\n\t\tList<Integer> input = List.of(0x1, 0xaabbccdd, 0x8844aa23);\n\n\t\tvar bitSet = ChunkMapUtils.toBitSet(input);\n\t\tvar output = ChunkMapUtils.toCompressedChunkMap(bitSet);\n\n\t\tassertArrayEquals(input.toArray(), output.toArray());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/filetransfer/ChunkTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.BLOCK_SIZE;\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferRsService.CHUNK_SIZE;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass ChunkTest\n{\n\t@Test\n\tvoid fillFullChunk()\n\t{\n\t\tvar chunk = new Chunk(CHUNK_SIZE);\n\t\tfor (int i = 0; i < CHUNK_SIZE - BLOCK_SIZE; i += BLOCK_SIZE)\n\t\t{\n\t\t\tchunk.setBlocksAsWritten(i, BLOCK_SIZE);\n\t\t\tassertFalse(chunk.isComplete());\n\t\t}\n\t\tchunk.setBlocksAsWritten(CHUNK_SIZE - BLOCK_SIZE, BLOCK_SIZE);\n\t\tassertTrue(chunk.isComplete());\n\t}\n\n\t@Test\n\tvoid fillPartialChunk()\n\t{\n\t\tvar chunk = new Chunk(CHUNK_SIZE - 5000);\n\t\tfor (int i = 0; i < CHUNK_SIZE - BLOCK_SIZE; i += BLOCK_SIZE)\n\t\t{\n\t\t\tchunk.setBlocksAsWritten(i, BLOCK_SIZE);\n\t\t\tassertFalse(chunk.isComplete());\n\t\t}\n\t\tchunk.setBlocksAsWritten(CHUNK_SIZE - BLOCK_SIZE, BLOCK_SIZE);\n\t\tassertTrue(chunk.isComplete());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/filetransfer/FileDownloadTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.common.util.OsUtils;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.nio.file.Paths;\n\nimport static io.xeres.app.xrs.service.filetransfer.FileTransferStrategy.LINEAR;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass FileDownloadTest\n{\n\tprivate static String tempDir;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\ttempDir = System.getProperty(\"java.io.tmpdir\");\n\t}\n\n\t@Test\n\tvoid Sparse_Success()\n\t{\n\t\tvar file = Paths.get(tempDir, \"sparsefile.tmp\").toFile();\n\t\tvar fileLeecher = new FileDownload(0L, file, 16384, null, LINEAR);\n\t\tfileLeecher.open();\n\t\tassertEquals(16384, fileLeecher.getFileSize());\n\t\tfileLeecher.close();\n\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\tassertEquals(\"This file is set as sparse\\n\", OsUtils.shellExecute(\"fsutil\", \"sparse\", \"queryflag\", file.getAbsolutePath()));\n\t\t}\n\t\telse if (SystemUtils.IS_OS_LINUX || SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\tvar result = OsUtils.shellExecute(\"ls\", \"-lsk\", file.getAbsolutePath());\n\t\t\tvar s = result.split(\" \");\n\t\t\tvar storageSize = Integer.parseInt(s[0]) * 1024;\n\t\t\tvar fileSize = Integer.parseInt(s[5]);\n\n\t\t\tassertTrue(storageSize < fileSize);\n\t\t}\n\t\t//noinspection ResultOfMethodCallIgnored\n\t\tfile.delete();\n\t}\n\n\t@Test\n\tvoid Read_NotAvailable()\n\t{\n\t\tvar file = Paths.get(tempDir, \"filesize.tmp\").toFile();\n\t\tvar fileLeecher = new FileDownload(0L, file, 256, null, LINEAR);\n\t\tfileLeecher.open();\n\t\tassertThrows(IOException.class, () -> fileLeecher.read(0, 256));\n\t\tfileLeecher.close();\n\t\t//noinspection ResultOfMethodCallIgnored\n\t\tfile.delete();\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/filetransfer/FileTransferAgentTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.testutils.Sha1SumFakes;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.io.IOException;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass FileTransferAgentTest\n{\n\t@Mock\n\tprivate FileTransferRsService fileTransferRsService;\n\n\t@Mock\n\tprivate FileProvider fileProvider;\n\n\t@Test\n\tvoid processLeecher() throws IOException\n\t{\n\t\tvar leecher = LocationFakes.createLocation();\n\t\tvar hash = Sha1SumFakes.createSha1Sum();\n\n\t\tvar agent = new FileTransferAgent(fileTransferRsService, \"foo\", hash, fileProvider);\n\n\t\twhen(fileProvider.getFileSize()).thenReturn(1024L); // Same file size\n\t\twhen(fileProvider.read(0L, 1024)).thenReturn(new byte[1024]);\n\n\t\tagent.addLeecher(leecher, 0, 1024);\n\t\tassertTrue(agent.process());\n\n\t\tverify(fileTransferRsService).sendData(eq(leecher), eq(hash), eq(1024L), eq(0L), any());\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/filetransfer/FileUploadTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.filetransfer;\n\nimport org.apache.commons.lang3.RandomUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.file.Files;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass FileUploadTest\n{\n\tprivate static final int TEMP_FILE_SIZE = 256;\n\n\tprivate static File createTempFile(int size) throws IOException\n\t{\n\t\tvar tempFile = Files.createTempFile(\"fileseeder\", \".tmp\").toFile();\n\t\tif (size > 0)\n\t\t{\n\t\t\tFiles.write(tempFile.toPath(), RandomUtils.insecure().randomBytes(size));\n\t\t}\n\t\treturn tempFile;\n\t}\n\n\tprivate static void deleteTempFile(File file) throws IOException\n\t{\n\t\tFiles.deleteIfExists(file.toPath());\n\t}\n\n\t@Test\n\tvoid GetFileSize_NotInitialized() throws IOException\n\t{\n\t\tvar tempFile = createTempFile(0);\n\t\tvar fileSeeder = new FileUpload(tempFile);\n\t\tassertThrows(IllegalStateException.class, fileSeeder::getFileSize);\n\t\tdeleteTempFile(tempFile);\n\t}\n\n\t@Test\n\tvoid GetFileSize_Success() throws IOException\n\t{\n\t\tvar tempFile = createTempFile(TEMP_FILE_SIZE);\n\t\tvar fileSeeder = new FileUpload(tempFile);\n\t\tfileSeeder.open();\n\t\tassertEquals(TEMP_FILE_SIZE, fileSeeder.getFileSize());\n\t\tfileSeeder.close();\n\t\tdeleteTempFile(tempFile);\n\t}\n\n\t@Test\n\tvoid Write_Illegal() throws IOException\n\t{\n\t\tvar tempFile = createTempFile(TEMP_FILE_SIZE);\n\t\tvar fileSeeder = new FileUpload(tempFile);\n\t\tfileSeeder.open();\n\t\tassertThrows(IllegalArgumentException.class, () -> fileSeeder.write(0, new byte[]{1, 2, 3}));\n\t\tfileSeeder.close();\n\t\tdeleteTempFile(tempFile);\n\t}\n\n\t@Test\n\tvoid Read_Success() throws IOException\n\t{\n\t\tvar tempFile = createTempFile(TEMP_FILE_SIZE);\n\t\tvar fileSeeder = new FileUpload(tempFile);\n\t\tfileSeeder.open();\n\t\tassertArrayEquals(Files.readAllBytes(tempFile.toPath()), fileSeeder.read(0, TEMP_FILE_SIZE));\n\t\tfileSeeder.close();\n\t\tdeleteTempFile(tempFile);\n\t}\n\n\t@Test\n\tvoid GetCompressedChunkMap_Success() throws IOException\n\t{\n\t\tvar tempFile = createTempFile(TEMP_FILE_SIZE);\n\t\tvar fileSeeder = new FileUpload(tempFile);\n\t\tfileSeeder.open();\n\t\tassertTrue(fileSeeder.getChunkMap().get(0));\n\t\tfileSeeder.close();\n\t\tdeleteTempFile(tempFile);\n\t}\n\n\t@Test\n\tvoid IsComplete_Success() throws IOException\n\t{\n\t\tvar tempFile = createTempFile(0);\n\t\tvar fileSeeder = new FileUpload(tempFile);\n\t\tfileSeeder.isComplete();\n\t\tassertTrue(fileSeeder.isComplete());\n\t\tdeleteTempFile(tempFile);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/gxs/GxsRequestTypeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.gxs.item.RequestType.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass GxsRequestTypeTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, NONE.ordinal());\n\t\tassertEquals(1, REQUEST.ordinal());\n\t\tassertEquals(2, RESPONSE.ordinal());\n\n\t\tassertEquals(3, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/gxs/GxsSignatureTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport io.netty.buffer.Unpooled;\nimport io.xeres.app.crypto.rsa.RSA;\nimport io.xeres.app.database.model.gxs.IdentityGroupItemFakes;\nimport io.xeres.app.xrs.item.Item;\nimport io.xeres.app.xrs.item.RawItem;\nimport io.xeres.app.xrs.serialization.SerializationFlags;\nimport io.xeres.app.xrs.service.identity.IdentityRsService;\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Instant;\nimport java.util.EnumSet;\n\nimport static io.xeres.app.net.peer.packet.Packet.HEADER_SIZE;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nclass GxsSignatureTest\n{\n\t@Test\n\tvoid Create_And_Verify_Success()\n\t{\n\t\tvar gxsIdGroupItem = IdentityGroupItemFakes.createIdentityGroupItem();\n\n\t\tvar keyPair = RSA.generateKeys(512);\n\n\t\tgxsIdGroupItem.setAdminKeys(keyPair.getPrivate(), keyPair.getPublic(), Instant.now(), null);\n\n\t\tvar data = serializeItemForSignature(gxsIdGroupItem);\n\n\t\tvar signature = RSA.sign(gxsIdGroupItem.getAdminPrivateKey(), data);\n\t\tgxsIdGroupItem.setAdminSignature(signature);\n\n\t\tvar rawItem = serializeItem(gxsIdGroupItem);\n\t\tassertNotNull(rawItem);\n\n\t\trawItem.getBuffer().release();\n\t}\n\n\tprivate RawItem serializeItem(Item item)\n\t{\n\t\titem.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null, null));\n\t\treturn item.serializeItem(EnumSet.noneOf(SerializationFlags.class));\n\t}\n\n\tprivate byte[] serializeItemForSignature(Item item)\n\t{\n\t\titem.setOutgoing(Unpooled.buffer().alloc(), new IdentityRsService(null, null, null, null, null, null, null, null, null, null));\n\t\tvar buf = item.serializeItem(EnumSet.of(SerializationFlags.SIGNATURE)).getBuffer();\n\t\tvar data = new byte[buf.writerIndex() - HEADER_SIZE];\n\t\tbuf.getBytes(HEADER_SIZE, data);\n\t\tbuf.release();\n\t\treturn data;\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/gxs/TransactionFlagsTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.gxs.item.TransactionFlags.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass TransactionFlagsTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, START.ordinal());\n\t\tassertEquals(1, START_ACKNOWLEDGE.ordinal());\n\t\tassertEquals(2, END_SUCCESS.ordinal());\n\t\tassertEquals(3, CANCEL.ordinal());\n\t\tassertEquals(4, END_FAIL_NUM.ordinal());\n\t\tassertEquals(5, END_FAIL_TIMEOUT.ordinal());\n\t\tassertEquals(6, END_FAIL_FULL.ordinal());\n\t\tassertEquals(8, TYPE_GROUP_LIST_RESPONSE.ordinal());\n\t\tassertEquals(9, TYPE_MESSAGE_LIST_RESPONSE.ordinal());\n\t\tassertEquals(10, TYPE_GROUP_LIST_REQUEST.ordinal());\n\t\tassertEquals(11, TYPE_MESSAGE_LIST_REQUEST.ordinal());\n\t\tassertEquals(12, TYPE_GROUPS.ordinal());\n\t\tassertEquals(13, TYPE_MESSAGES.ordinal());\n\t\tassertEquals(14, TYPE_ENCRYPTED_DATA.ordinal());\n\n\t\tassertEquals(15, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/gxs/TransactionTest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs;\n\nimport io.xeres.app.xrs.service.gxs.item.GxsSyncGroupItem;\nimport io.xeres.app.xrs.service.gxs.item.TransactionFlags;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.ArrayList;\nimport java.util.EnumSet;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TransactionTest\n{\n\t@Test\n\tvoid AddItems_Success()\n\t{\n\t\tvar transaction = new Transaction<GxsSyncGroupItem>(1, EnumSet.noneOf(TransactionFlags.class), new ArrayList<>(), 2, null, Transaction.Direction.INCOMING);\n\n\t\ttransaction.addItem(new GxsSyncGroupItem());\n\t\ttransaction.addItem(new GxsSyncGroupItem());\n\n\t\tassertEquals(1, transaction.getId());\n\t\tassertFalse(transaction.hasTimedOut());\n\t\tassertTrue(transaction.hasAllItems());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/gxs/item/GxsSyncMessageRequestItemTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxs.item;\n\nimport io.xeres.common.id.GxsId;\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Duration;\nimport java.time.Instant;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass GxsSyncMessageRequestItemTest\n{\n\t@Test\n\tvoid testGxsSyncMessageRequestItem()\n\t{\n\t\tvar gxsId = GxsId.fromString(\"11111111111111111111111111111111\");\n\t\tvar now = Instant.now();\n\t\tvar lastUpdated = now.minus(Duration.ofDays(30));\n\t\tvar syncLimit = Duration.ofDays(365);\n\n\t\tvar request = new GxsSyncMessageRequestItem(gxsId, lastUpdated, syncLimit);\n\n\t\tassertEquals(lastUpdated.getEpochSecond(), request.getLastUpdated());\n\t\tassertTrue(Math.abs(now.minus(syncLimit).getEpochSecond() - request.getLimit()) <= 1); // GxsSyncMessageRequestItem uses Instant.now() internally so we have to give some slack\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/gxstunnel/TunnelPeerInfoTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.gxstunnel;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass TunnelPeerInfoTest\n{\n\n\t@Test\n\tvoid checkIfMessageAlreadyReceivedAndRecord()\n\t{\n\t\tvar tunnelPeerInfo = new TunnelPeerInfo();\n\n\t\tassertFalse(tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(1L));\n\t\tassertFalse(tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(2L));\n\t\tassertTrue(tunnelPeerInfo.checkIfMessageAlreadyReceivedAndRecord(1L));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/heartbeat/HeartbeatTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.heartbeat;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.xrs.service.heartbeat.item.HeartbeatItem;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\n@ExtendWith(MockitoExtension.class)\nclass HeartbeatTest\n{\n\t@InjectMocks\n\tprivate HeartbeatRsService heartbeatRsService;\n\n\t@Test\n\t@SuppressWarnings(\"java:S2699\")\n\tvoid HandleHeartbeat_Success()\n\t{\n\t\tvar peerConnection = new PeerConnection(Location.createLocation(\"foo\"), null);\n\n\t\theartbeatRsService.handleItem(peerConnection, new HeartbeatItem());\n\n\t\t// The service does nothing\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/identity/IdentityManagerTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nimport io.xeres.app.database.model.gxs.IdentityGroupItemFakes;\nimport io.xeres.app.net.peer.PeerConnectionFakes;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.common.id.GxsId;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.*;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass IdentityManagerTest\n{\n\t@Mock\n\tprivate IdentityRsService identityRsService;\n\n\t@Mock\n\tprivate IdentityService identityService;\n\n\t@Mock\n\tprivate PeerConnectionManager peerConnectionManager;\n\n\t@InjectMocks\n\tprivate IdentityManager identityManager;\n\n\t@Test\n\tvoid AddOneAndRequest_Success()\n\t{\n\t\tvar gxsId = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar peerConnection = PeerConnectionFakes.createPeerConnection();\n\n\t\twhen(identityService.findByGxsId(gxsId.getGxsId())).thenReturn(Optional.empty());\n\t\twhen(peerConnectionManager.getPeerByLocation(peerConnection.getLocation().getId())).thenReturn(peerConnection);\n\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId.getGxsId());\n\n\t\tidentityManager.requestGxsIds();\n\n\t\tverify(identityRsService).requestGxsGroups(peerConnection, List.of(gxsId.getGxsId()));\n\t}\n\n\t@Test\n\t@SuppressWarnings(\"unchecked\")\n\tvoid AddSixAndRequest_Success()\n\t{\n\t\tvar gxsId1 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsId2 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsId3 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsId4 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsId5 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar gxsId6 = IdentityGroupItemFakes.createIdentityGroupItem();\n\t\tvar peerConnection = PeerConnectionFakes.createPeerConnection();\n\n\t\twhen(identityService.findByGxsId(any(GxsId.class))).thenReturn(Optional.empty());\n\t\twhen(peerConnectionManager.getPeerByLocation(anyLong())).thenReturn(peerConnection);\n\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId1.getGxsId());\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId2.getGxsId());\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId3.getGxsId());\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId4.getGxsId());\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId5.getGxsId());\n\t\tidentityManager.getGxsGroup(peerConnection, gxsId6.getGxsId());\n\n\t\tidentityManager.requestGxsIds();\n\n\t\tArgumentCaptor<List<GxsId>> ids = ArgumentCaptor.forClass(List.class);\n\t\tverify(identityRsService).requestGxsGroups(eq(peerConnection), ids.capture());\n\n\t\tassertEquals(5, ids.getValue().size());\n\n\t\tSet<GxsId> allGxsIds = new HashSet<>();\n\t\tallGxsIds.add(gxsId1.getGxsId());\n\t\tallGxsIds.add(gxsId2.getGxsId());\n\t\tallGxsIds.add(gxsId3.getGxsId());\n\t\tallGxsIds.add(gxsId4.getGxsId());\n\t\tallGxsIds.add(gxsId5.getGxsId());\n\t\tallGxsIds.add(gxsId6.getGxsId());\n\t\tids.getValue().forEach(allGxsIds::remove);\n\t\tassertEquals(1, allGxsIds.size());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/identity/IdentityRsServiceTest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.identity;\n\nimport io.xeres.app.crypto.pgp.PGP;\nimport io.xeres.app.database.model.gxs.GxsMessageItem;\nimport io.xeres.app.database.model.identity.IdentityFakes;\nimport io.xeres.app.database.model.profile.ProfileFakes;\nimport io.xeres.app.service.IdentityService;\nimport io.xeres.app.service.ProfileService;\nimport io.xeres.app.service.SettingsService;\nimport io.xeres.app.service.notification.contact.ContactNotificationService;\nimport io.xeres.app.xrs.service.gxs.GxsHelperService;\nimport io.xeres.app.xrs.service.identity.item.IdentityGroupItem;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.ProfileFingerprint;\nimport jakarta.persistence.EntityNotFoundException;\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.mock.web.MockMultipartFile;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport java.io.IOException;\nimport java.security.Security;\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass IdentityRsServiceTest\n{\n\t@Mock\n\tprivate SettingsService settingsService;\n\n\t@Mock\n\tprivate ProfileService profileService;\n\n\t@Mock\n\tprivate IdentityService identityService;\n\n\t@Mock\n\tprivate GxsHelperService<IdentityGroupItem, GxsMessageItem> gxsHelperService;\n\n\t@Mock\n\tprivate ContactNotificationService contactNotificationService;\n\n\t@InjectMocks\n\tprivate IdentityRsService identityRsService;\n\n\t@BeforeAll\n\tstatic void setup()\n\t{\n\t\tSecurity.addProvider(new BouncyCastleProvider());\n\t}\n\n\t@Test\n\tvoid CreateOwnIdentity_Anonymous_Success()\n\t{\n\t\tvar name = \"test\";\n\n\t\twhen(settingsService.isOwnProfilePresent()).thenReturn(true);\n\t\twhen(settingsService.hasOwnLocation()).thenReturn(true);\n\t\twhen(identityService.save(any(IdentityGroupItem.class))).thenAnswer(invocation -> invocation.getArguments()[0]);\n\n\t\tidentityRsService.generateOwnIdentity(name, false);\n\n\t\tvar gxsIdGroupItem = ArgumentCaptor.forClass(IdentityGroupItem.class);\n\t\tverify(identityService).save(gxsIdGroupItem.capture());\n\t\tassertEquals(name, gxsIdGroupItem.getValue().getName());\n\t}\n\n\t@Test\n\tvoid CreateOwnIdentity_Signed_Success() throws IOException\n\t{\n\t\tvar name = \"test\";\n\n\t\tvar encodedKey = new byte[]{-107, 1, 30, 4, 96, -83, 89, -119, 1, 2, 0, -124, 36, -16, 89, 77, 70, 111, 82, 42, 104, 115, 27, 52, -67, 56, -116, 80, 71, 109, -9,\n\t\t\t\t78, -113, 115, -22, -35, 97, 121, 34, -118, 90, -6, -68, 113, 78, -58, -120, -4, -123, -1, 46, 10, -19, 122, -84, 21, -24, 118, 82, 12, -1, 45, -56, -94, -21, -25, -3, -68, 17, 45,\n\t\t\t\t9, -26, -33, 86, -53, 0, 17, 1, 0, 1, -2, 3, 3, 2, 120, 82, -62, 47, -20, 15, -47, -114, 96, -60, -67, 67, 56, -82, 79, -17, 82, -40, 17, 72, 39, -53, -72, 25, 52, -94, 103, -31,\n\t\t\t\t92, -51, 53, -29, 119, -26, 20, 81, 94, -29, -20, 104, 103, 56, -53, -53, 28, 6, -82, -33, 92, -31, -18, -4, 73, 55, 97, -89, 38, -21, 123, 30, -28, 76, -122, 20, 89, -28, -112,\n\t\t\t\t-29, 32, -116, -75, -19, -113, 123, -23, -42, 122, 13, 1, -46, -70, -69, 87, -41, -104, -49, 101, 22, 79, -63, -112, -120, 79, 25, 16, -2, -77, 118, 110, -109, -33, -100, -11,\n\t\t\t\t-126, -73, -64, 125, 56, 101, 49, -89, 19, -61, 125, 103, 121, 82, -15, 109, 2, 105, -103, -11, 31, -68, -117, -81, -14, 7, -9, 98, 18, 96, -26, 70, 66, -64, 108, -2, -6, 114, -13,\n\t\t\t\t44, -103, 81, -28, 80, 115, 124, 74, -28, -53, 53, -44, -118, 20, -94, -113, -43, 109, 111, 82, -21, 34, 80, -50, 62, 127, -38, -10, 108, -49, -123, 44, -39, 116, -90, 61, 41, -40,\n\t\t\t\t-127, -84, 111, -127, -68, -75, 106, -9, -81, 37, -40, -120, 36, 62, 12, 45, 15, -88, 9, -51, -24, -96, 68, -38, 125, -76, 4, 116, 101, 115, 116, -120, 92, 4, 16, 1, 2, 0, 6, 5, 2,\n\t\t\t\t96, -83, 89, -119, 0, 10, 9, 16, -119, -55, 33, -4, 60, -108, 116, -23, -92, -19, 1, -4, 10, -89, 1, 44, 82, -29, 24, 104, -128, -73, -96, 122, -38, 67, -120, 18, 62, 10, 3, 95, 27,\n\t\t\t\t-51, -45, -114, -113, -93, 118, 13, -20, 3, -35, 8, 15, 97, 27, 76, 20, 9, 78, 74, -24, 27, -99, -58, -125, -69, -103, -13, 50, -83, -117, -115, -123, 25, 52, 39, -122, -22, 81, 46,\n\t\t\t\t84, 22, -52, 17};\n\n\t\tvar secretKey = PGP.getPGPSecretKey(encodedKey);\n\t\tvar publicKey = secretKey.getPublicKey();\n\t\tvar fingerprint = publicKey.getFingerprint();\n\n\t\tvar ownProfile = ProfileFakes.createProfile(name, PGP.getPGPIdentifierFromFingerprint(fingerprint), fingerprint, publicKey.getEncoded());\n\n\t\townProfile.setProfileFingerprint(new ProfileFingerprint(secretKey.getPublicKey().getFingerprint()));\n\t\townProfile.setPgpPublicKeyData(secretKey.getPublicKey().getEncoded());\n\n\t\twhen(settingsService.isOwnProfilePresent()).thenReturn(true);\n\t\twhen(settingsService.hasOwnLocation()).thenReturn(true);\n\t\twhen(profileService.getOwnProfile()).thenReturn(ownProfile);\n\t\twhen(settingsService.getSecretProfileKey()).thenReturn(encodedKey);\n\t\twhen(identityService.save(any(IdentityGroupItem.class))).thenAnswer(invocation -> invocation.getArguments()[0]);\n\n\t\tidentityRsService.generateOwnIdentity(name, true);\n\n\t\tvar gxsIdGroupItem = ArgumentCaptor.forClass(IdentityGroupItem.class);\n\t\tverify(identityService).save(gxsIdGroupItem.capture());\n\t\tassertEquals(name, gxsIdGroupItem.getValue().getName());\n\t\tassertNotNull(gxsIdGroupItem.getValue().getProfileHash());\n\t\tassertNotNull(gxsIdGroupItem.getValue().getProfileSignature());\n\t}\n\n\t@Test\n\tvoid SaveIdentityImage_Success() throws IOException\n\t{\n\t\tvar id = 1L;\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tvar file = new MockMultipartFile(\"file\", IdentityRsServiceTest.class.getResourceAsStream(\"/image/leguman.jpg\"));\n\n\t\twhen(identityService.findById(id)).thenReturn(Optional.of(identity));\n\t\twhen(identityService.save(identity)).thenReturn(identity);\n\n\t\tidentityRsService.saveOwnIdentityImage(id, file);\n\n\t\tassertNotNull(identity.getImage());\n\n\t\tverify(identityService).findById(id);\n\t\tverify(identityService).save(identity);\n\t}\n\n\t@Test\n\tvoid SaveIdentityImage_NotOwn_Error()\n\t{\n\t\tvar id = 2L;\n\t\tvar file = mock(MultipartFile.class);\n\n\t\tassertThrows(EntityNotFoundException.class, () -> identityRsService.saveOwnIdentityImage(id, file));\n\t}\n\n\t@Test\n\tvoid SaveIdentityImage_EmptyImage_Error()\n\t{\n\t\tvar id = 1L;\n\n\t\tassertThrows(IllegalArgumentException.class, () -> identityRsService.saveOwnIdentityImage(id, null));\n\t}\n\n\t@Test\n\tvoid SaveIdentityImage_ImageTooBig_Error()\n\t{\n\t\tvar id = 1L;\n\t\tvar file = mock(MultipartFile.class);\n\t\twhen(file.getSize()).thenReturn(1024 * 1024 * 11L);\n\n\t\twhen(identityService.findById(id)).thenReturn(Optional.of(IdentityFakes.createOwn()));\n\n\t\tassertThrows(IllegalArgumentException.class, () -> identityRsService.saveOwnIdentityImage(id, file), \"Avatar image size is bigger than \" + (1024 * 1024 * 10) + \" bytes\");\n\t}\n\n\t@Test\n\tvoid DeleteIdentityImage_Success()\n\t{\n\t\tvar id = 1L;\n\t\tvar identity = IdentityFakes.createOwn();\n\t\tidentity.setImage(new byte[1]);\n\n\t\twhen(identityService.findById(id)).thenReturn(Optional.of(identity));\n\t\twhen(identityService.save(identity)).thenReturn(identity);\n\n\t\tidentityRsService.deleteOwnIdentityImage(id);\n\n\t\tassertNull(identity.getImage());\n\n\t\tverify(identityService).findById(id);\n\t\tverify(identityService).save(identity);\n\t}\n\n\t@Test\n\tvoid DeleteIdentityImage_NotOwn_Error()\n\t{\n\t\tvar id = 2L;\n\n\t\tassertThrows(EntityNotFoundException.class, () -> identityRsService.deleteOwnIdentityImage(id));\n\t}\n\n\t@Test\n\tvoid MakeProfileHash_Success()\n\t{\n\t\tvar computedHash = IdentityRsService.makeProfileHash(GxsId.fromString(\"bb3851c00134a29f921cb3643a4525a9\"), new ProfileFingerprint(Id.toBytes(\"C984CC1237437B5983A2031070DC1676FA60F825\")));\n\t\tassertEquals(\"778db3511ba29027dd85f324c58717d05c4e3f30\", computedHash.toString());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/rtt/RttRsServiceTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.rtt;\n\nimport io.xeres.app.database.model.location.Location;\nimport io.xeres.app.net.peer.PeerConnection;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.xrs.service.RsService;\nimport io.xeres.app.xrs.service.rtt.item.RttPingItem;\nimport io.xeres.app.xrs.service.rtt.item.RttPongItem;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\n\n@ExtendWith(MockitoExtension.class)\nclass RttRsServiceTest\n{\n\t@Mock\n\tprivate PeerConnectionManager peerConnectionManager;\n\n\t@InjectMocks\n\tprivate RttRsService rttRsService;\n\n\t@Test\n\tvoid HandlePing_Success()\n\t{\n\t\tvar sequence = 1;\n\t\tvar timestamp = 2L;\n\n\t\tvar peerConnection = new PeerConnection(Location.createLocation(\"foo\"), null);\n\n\t\trttRsService.handleItem(peerConnection, new RttPingItem(sequence, timestamp));\n\n\t\tvar rttPongItem = ArgumentCaptor.forClass(RttPongItem.class);\n\t\tverify(peerConnectionManager).writeItem(eq(peerConnection), rttPongItem.capture(), any(RsService.class));\n\n\t\tassertEquals(timestamp, rttPongItem.getValue().getPingTimestamp());\n\t\tassertNotEquals(0, rttPongItem.getValue().getPongTimestamp());\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/status/IdleCheckerTest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass IdleCheckerTest\n{\n\t@Mock\n\tprivate GetIdleTime getIdleTime;\n\n\t@InjectMocks\n\tIdleChecker idleChecker;\n\n\t@Test\n\tvoid GetIdleTime()\n\t{\n\t\tvar idleTime = 1;\n\n\t\twhen(getIdleTime.getIdleTime()).thenReturn(idleTime);\n\n\t\tvar result = idleChecker.getIdleTime();\n\n\t\tassertEquals(idleTime, result);\n\n\t\tverify(getIdleTime).getIdleTime();\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/status/StatusRsServiceTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.net.peer.PeerConnectionManager;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.service.notification.availability.AvailabilityNotificationService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.Optional;\n\nimport static io.xeres.common.location.Availability.*;\nimport static org.mockito.Mockito.*;\n\n@ExtendWith(MockitoExtension.class)\nclass StatusRsServiceTest\n{\n\n\t@Mock\n\tprivate PeerConnectionManager peerConnectionManager;\n\n\t@Mock\n\tprivate LocationService locationService;\n\n\t@Mock\n\tprivate AvailabilityNotificationService availabilityNotificationService;\n\n\t@Mock\n\tprivate DatabaseSessionManager databaseSessionManager;\n\n\t@InjectMocks\n\tprivate StatusRsService statusRsService;\n\n\t@Test\n\tvoid Change_Availability_All_Success()\n\t{\n\t\tvar ownLocation = LocationFakes.createOwnLocation();\n\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of((ownLocation)));\n\n\t\tstatusRsService.changeAvailability(BUSY);\n\t\tstatusRsService.changeAvailability(AWAY);\n\t\tstatusRsService.changeAvailability(AVAILABLE);\n\n\t\tverify(availabilityNotificationService).changeAvailability(ownLocation, BUSY);\n\t\tverify(availabilityNotificationService).changeAvailability(ownLocation, AWAY);\n\t\tverify(availabilityNotificationService).changeAvailability(ownLocation, AVAILABLE);\n\t}\n\n\t@Test\n\tvoid Change_Availability_Away_And_Back_Success()\n\t{\n\t\tvar ownLocation = LocationFakes.createOwnLocation();\n\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of((ownLocation)));\n\n\t\tstatusRsService.changeAvailability(AWAY);\n\t\tstatusRsService.changeAvailability(AVAILABLE);\n\t\tstatusRsService.changeAvailability(AWAY);\n\n\t\tverify(availabilityNotificationService).changeAvailability(ownLocation, AVAILABLE);\n\t\tverify(availabilityNotificationService, times(2)).changeAvailability(ownLocation, AWAY);\n\t}\n\n\t@Test\n\tvoid Manual_Prevents_Automatic()\n\t{\n\t\tvar ownLocation = LocationFakes.createOwnLocation();\n\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of((ownLocation)));\n\n\t\tstatusRsService.changeAvailabilityAutomatically(AWAY);\n\t\tstatusRsService.changeAvailabilityAutomatically(AVAILABLE);\n\n\t\t// Lock\n\t\tstatusRsService.changeAvailability(BUSY);\n\n\t\tstatusRsService.changeAvailabilityAutomatically(AWAY);\n\n\t\t// Unlock\n\t\tstatusRsService.changeAvailability(AVAILABLE);\n\n\t\tstatusRsService.changeAvailabilityAutomatically(AWAY);\n\n\t\tverify(availabilityNotificationService, times(2)).changeAvailability(ownLocation, AVAILABLE);\n\t\tverify(availabilityNotificationService, times(2)).changeAvailability(ownLocation, AWAY);\n\t\tverify(availabilityNotificationService).changeAvailability(ownLocation, BUSY);\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/status/StatusTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.status;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.app.xrs.service.status.ChatStatus.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass StatusTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, OFFLINE.ordinal());\n\t\tassertEquals(1, AWAY.ordinal());\n\t\tassertEquals(2, BUSY.ordinal());\n\t\tassertEquals(3, ONLINE.ordinal());\n\t\tassertEquals(4, INACTIVE.ordinal());\n\n\t\tassertEquals(5, values().length);\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/turtle/HashBloomFilterTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.service.file.HashBloomFilter;\nimport io.xeres.testutils.Sha1SumFakes;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass HashBloomFilterTest\n{\n\t@Test\n\tvoid Add_Success()\n\t{\n\t\tvar filter = new HashBloomFilter(null, 10_000, 0.01d);\n\n\t\tvar s1 = Sha1SumFakes.createSha1Sum();\n\t\tvar s2 = Sha1SumFakes.createSha1Sum();\n\n\t\tfilter.add(s1);\n\t\tfilter.add(s2);\n\n\t\tassertTrue(filter.mightContain(s1));\n\t\tassertTrue(filter.mightContain(s2));\n\n\t\tfilter.clear();\n\n\t\tassertFalse(filter.mightContain(s1));\n\t\tassertFalse(filter.mightContain(s2));\n\t}\n\n\t@Test\n\tvoid Add_Multiple_Success()\n\t{\n\t\tvar filter = new HashBloomFilter(null, 10_000, 0.01d);\n\n\t\tvar s1 = Sha1SumFakes.createSha1Sum();\n\t\tvar s2 = Sha1SumFakes.createSha1Sum();\n\t\tvar s3 = Sha1SumFakes.createSha1Sum();\n\t\tvar s4 = Sha1SumFakes.createSha1Sum();\n\t\tvar s5 = Sha1SumFakes.createSha1Sum();\n\t\tvar s6 = Sha1SumFakes.createSha1Sum();\n\n\n\t\tvar in = List.of(s1, s2, s3);\n\t\tvar out = List.of(s4, s5, s6);\n\n\t\tfilter.addAll(in);\n\n\t\tassertTrue(filter.mightContainAll(in));\n\t\tassertFalse(filter.mightContainAll(out));\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/app/xrs/service/turtle/TurtleRsServiceTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.app.xrs.service.turtle;\n\nimport io.xeres.app.database.DatabaseSessionManager;\nimport io.xeres.app.database.model.location.LocationFakes;\nimport io.xeres.app.service.LocationService;\nimport io.xeres.app.xrs.service.turtle.item.TurtleTunnelRequestItem;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass TurtleRsServiceTest\n{\n\t@Mock\n\tprivate LocationService locationService;\n\n\t@Mock\n\tprivate DatabaseSessionManager databaseSessionManager;\n\n\t@InjectMocks\n\tprivate TurtleRsService turtleRsService;\n\n\t@Test\n\tvoid GeneratePersonalFilePrint_Success()\n\t{\n\t\t// Values have been taken directly from Retroshare to make sure there's no signed/unsigned bugs\n\t\tvar ownLocation = LocationFakes.createLocation(\"Test\", null, LocationIdentifier.fromString(\"d3b9c7ceb75c7c68b5e3c6446259c8e7\"));\n\n\t\twhen(locationService.findOwnLocation()).thenReturn(Optional.of(ownLocation));\n\t\tturtleRsService.initialize();\n\n\t\tvar item = mock(TurtleTunnelRequestItem.class);\n\t\twhen(item.getHash()).thenReturn(new Sha1Sum(Id.toBytes(\"ac39b8f761465b1460948973e8fe754f4e101700\")));\n\t\tvar result = turtleRsService.generatePersonalFilePrint(item.getHash(), 1_833_303_450, true);\n\n\t\tassertEquals(3_280_770_886L, Integer.toUnsignedLong(result));\n\t}\n}"
  },
  {
    "path": "app/src/test/java/io/xeres/testutils/FakeHttpServer.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\n\nimport java.io.IOException;\nimport java.net.BindException;\nimport java.net.InetSocketAddress;\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic class FakeHttpServer\n{\n\tprivate int port;\n\tprivate final HttpServer httpServer;\n\tprivate byte[] requestBody;\n\n\tpublic FakeHttpServer(String path, int responseCode, byte[] responseBody)\n\t{\n\t\tport = ThreadLocalRandom.current().nextInt(2048, 28000);\n\t\thttpServer = createHttpServer();\n\n\t\tvar handler = (HttpHandler) exchange -> {\n\t\t\trequestBody = exchange.getRequestBody().readAllBytes();\n\t\t\texchange.sendResponseHeaders(responseCode, responseBody != null ? responseBody.length : -1);\n\t\t\tif (responseBody != null)\n\t\t\t{\n\t\t\t\texchange.getResponseBody().write(responseBody);\n\t\t\t}\n\t\t\texchange.close();\n\t\t};\n\t\thttpServer.createContext(path, handler);\n\n\t\thttpServer.start();\n\t}\n\n\tpublic byte[] getRequestBody()\n\t{\n\t\treturn requestBody;\n\t}\n\n\tpublic void shutdown()\n\t{\n\t\thttpServer.stop(0);\n\t}\n\n\tpublic int getPort()\n\t{\n\t\treturn port;\n\t}\n\n\tprivate HttpServer createHttpServer()\n\t{\n\t\tvar address = new InetSocketAddress(port);\n\t\ttry\n\t\t{\n\t\t\treturn HttpServer.create(address, 0);\n\t\t}\n\t\tcatch (BindException _)\n\t\t{\n\t\t\tport++;\n\t\t\treturn createHttpServer();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(\"I/O error: \" + e.getMessage());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/test/java/io/xeres/testutils/ResourceUtils.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport java.io.File;\nimport java.io.FileOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\n\npublic final class ResourceUtils\n{\n\tprivate ResourceUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static File getResourceAsFile(String resourcePath)\n\t{\n\t\ttry (InputStream in = ResourceUtils.class.getClassLoader().getResourceAsStream(resourcePath))\n\t\t{\n\t\t\tif (in == null) return null;\n\t\t\tFile tempFile = File.createTempFile(\"xeres_test_resource_\", \".tmp\");\n\t\t\ttempFile.deleteOnExit();\n\t\t\ttry (FileOutputStream out = new FileOutputStream(tempFile))\n\t\t\t{\n\t\t\t\tbyte[] buffer = new byte[1024];\n\t\t\t\tint bytesRead;\n\t\t\t\twhile ((bytesRead = in.read(buffer)) != -1)\n\t\t\t\t{\n\t\t\t\t\tout.write(buffer, 0, bytesRead);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn tempFile;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(\"Couldn't copy test resource: \" + e.getMessage());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "app/src/test/resources/application-default.properties",
    "content": "#\n# Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\nspring.datasource.url=jdbc:h2:mem:userdata\nserver.port=0\nserver.ssl.enabled=false"
  },
  {
    "path": "app/src/test/resources/upnp/routers/RT-AC87U.xml",
    "content": "<?xml version=\"1.0\"?>\n<root xmlns=\"urn:schemas-upnp-org:device-1-0\" configId=\"1337\">\n    <specVersion>\n        <major>1</major>\n        <minor>1</minor>\n    </specVersion>\n    <device>\n        <deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>\n        <friendlyName>RT-AC87U</friendlyName>\n        <manufacturer>ASUSTek</manufacturer>\n        <manufacturerURL>http://www.asus.com/</manufacturerURL>\n        <modelDescription>ASUS Wireless Router</modelDescription>\n        <modelName>RT-AC87U</modelName>\n        <modelNumber>384.13</modelNumber>\n        <modelURL>http://www.asus.com/</modelURL>\n        <serialNumber>88:d7:f6:44:f8:d8</serialNumber>\n        <UDN>uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d8</UDN>\n        <serviceList>\n            <service>\n                <serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>\n                <serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>\n                <SCPDURL>/L3F.xml</SCPDURL>\n                <controlURL>/ctl/L3F</controlURL>\n                <eventSubURL>/evt/L3F</eventSubURL>\n            </service>\n        </serviceList>\n        <deviceList>\n            <device>\n                <deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>\n                <friendlyName>WANDevice</friendlyName>\n                <manufacturer>MiniUPnP</manufacturer>\n                <manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>\n                <modelDescription>WAN Device</modelDescription>\n                <modelName>WAN Device</modelName>\n                <modelNumber>20200628</modelNumber>\n                <modelURL>http://miniupnp.free.fr/</modelURL>\n                <serialNumber>88:d7:f6:44:f8:d8</serialNumber>\n                <UDN>uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8d9</UDN>\n                <UPC>000000000000</UPC>\n                <serviceList>\n                    <service>\n                        <serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>\n                        <serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>\n                        <SCPDURL>/WANCfg.xml</SCPDURL>\n                        <controlURL>/ctl/CmnIfCfg</controlURL>\n                        <eventSubURL>/evt/CmnIfCfg</eventSubURL>\n                    </service>\n                </serviceList>\n                <deviceList>\n                    <device>\n                        <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>\n                        <friendlyName>WANConnectionDevice</friendlyName>\n                        <manufacturer>MiniUPnP</manufacturer>\n                        <manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>\n                        <modelDescription>MiniUPnP daemon</modelDescription>\n                        <modelName>MiniUPnPd</modelName>\n                        <modelNumber>20200628</modelNumber>\n                        <modelURL>http://miniupnp.free.fr/</modelURL>\n                        <serialNumber>88:d7:f6:44:f8:d8</serialNumber>\n                        <UDN>uuid:3ddcd1d3-2380-45f5-b069-88d7f644f8da</UDN>\n                        <UPC>000000000000</UPC>\n                        <serviceList>\n                            <service>\n                                <serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>\n                                <serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>\n                                <SCPDURL>/WANIPCn.xml</SCPDURL>\n                                <controlURL>/ctl/IPConn</controlURL>\n                                <eventSubURL>/evt/IPConn</eventSubURL>\n                            </service>\n                        </serviceList>\n                    </device>\n                </deviceList>\n            </device>\n        </deviceList>\n        <presentationURL>http://192.168.1.1:80/</presentationURL>\n    </device>\n</root>"
  },
  {
    "path": "build.gradle",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nbuildscript {\n    ext {\n        apacheCommonsCollectionsVersion = \"4.5.0\"\n        apacheCommonsLangVersion = \"3.20.0\"\n        appDirsVersion = \"1.5.0\"\n        archunitVersion = \"1.4.2\"\n        bouncycastleVersion = \"1.84\"\n        commonMarkVersion = \"0.28.0\"\n        flywayDbVersion = \"11.7.2\" // Only used by the plugin, keep in sync with spring-boot from time to time\n        graalvmVersion = \"25.0.3\"\n        jnaVersion = \"5.18.1\"\n        jpackageVersion = \"2.0.1\"\n        jsoupVersion = \"1.22.2\"\n        junitVersion = \"6.0.3\"\n        sonarqubeVersion = \"7.3.0.8198\"\n        springBootVersion = \"4.0.6\"\n        springOpenApiVersion = \"3.0.3\"\n        twelveMonkeysVersion = \"3.13.1\"\n        zxingVersion = \"3.5.4\"\n    }\n}\n\nplugins {\n    id 'org.springframework.boot' version \"$springBootVersion\" apply false\n    id 'org.flywaydb.flyway' version \"$flywayDbVersion\" apply false\n    id 'org.panteleyev.jpackageplugin' version \"$jpackageVersion\" apply false\n    id 'org.sonarqube' version \"$sonarqubeVersion\"\n    id 'com.bakdata.mockito' version \"2.2.0\" apply false // This plugin loads mockito as a java agent to avoid warnings\n}\n\n// This gives a git-like version for git builds but a proper version\n// when the release is built with a tag\ndef getVersionName = providers.exec {\n    commandLine(\"git\", \"describe\", \"--tags\")\n}.standardOutput.asText.get().substring(1).trim()\n\nsubprojects {\n    group = 'io.xeres'\n    version = \"${getVersionName}\"\n\n    apply plugin: 'java'\n    apply plugin: 'jacoco'\n\n    java {\n        sourceCompatibility = '25'\n    }\n\n    compileJava {\n        options.encoding = 'UTF-8'\n    }\n\n    compileTestJava {\n        options.encoding = 'UTF-8'\n    }\n\n    repositories {\n        mavenCentral()\n    }\n}\n\nsonarqube {\n    properties {\n        property \"sonar.projectKey\", \"zapek_Xeres\"\n        property \"sonar.organization\", \"zapek\"\n        property \"sonar.host.url\", \"https://sonarcloud.io\"\n    }\n}\n"
  },
  {
    "path": "common/build.gradle",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nimport org.springframework.boot.gradle.plugin.SpringBootPlugin\n\n\n\nplugins {\n    id 'java-test-fixtures'\n    id 'com.bakdata.mockito'\n}\n\ntest {\n    useJUnitPlatform()\n    test.jvmArgs \"-ea\", \"-Djava.net.preferIPv4Stack=true\", \"-Dfile.encoding=UTF-8\"\n}\n\njacocoTestReport {\n    reports {\n        xml.required = true\n        html.required = false\n    }\n}\n\njavadoc {\n    options.overview = \"src/main/javadoc/overview.html\"\n}\n\ndependencies {\n    implementation(platform(SpringBootPlugin.BOM_COORDINATES))\n    testFixturesImplementation(platform(SpringBootPlugin.BOM_COORDINATES))\n    implementation 'org.springframework.boot:spring-boot-starter-validation'\n    implementation 'org.springframework.boot:spring-boot-starter-jackson'\n    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'\n    implementation \"org.apache.commons:commons-lang3:$apacheCommonsLangVersion\"\n    implementation \"org.springdoc:springdoc-openapi-starter-webmvc-api:$springOpenApiVersion\"\n    implementation \"net.harawata:appdirs:$appDirsVersion\"\n    implementation 'dev.mccue:imgscalr:2023.09.03'\n    implementation 'com.github.depsypher:pngtastic:1.8'\n    testImplementation \"org.junit.jupiter:junit-jupiter:$junitVersion\"\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n    testImplementation('org.springframework.boot:spring-boot-starter-test') {\n        exclude group: \"com.vaadin.external.google\", module: \"android-json\"\n    }\n    testImplementation \"com.tngtech.archunit:archunit-junit5:$archunitVersion\"\n    testFixturesImplementation('org.springframework.boot:spring-boot-starter-test') {\n        exclude group: \"com.vaadin.external.google\", module: \"android-json\"\n    }\n    testFixturesImplementation \"org.apache.commons:commons-lang3:$apacheCommonsLangVersion\"\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/AppName.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common;\n\npublic final class AppName\n{\n\tpublic static final String NAME = \"Xeres\";\n\n\tprivate AppName()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/Features.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common;\n\npublic final class Features\n{\n\t/**\n\t * Enable experimental generation of Elliptic Curve keys.\n\t */\n\tpublic static final boolean EXPERIMENTAL_EC = false;\n\n\t/**\n\t * Use patch for the settings. Should always be enabled\n\t * unless the patch support breaks. It currently relies on a Jackson\n\t * module and a default JSON-P implementation.\n\t */\n\tpublic static final boolean USE_PATCH_SETTINGS = true;\n\n\tprivate Features()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/annotation/RsDeprecated.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.annotation;\n\nimport java.lang.annotation.Documented;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\nimport static java.lang.annotation.ElementType.*;\n\n/**\n * Marks a program element as deprecated. It's the same as the {@link Deprecated} annotation, except\n * there's no compiler warnings about it and, hence, no urgency to remove them.\n * <p>\n * Old Retroshare clients can indeed stay in the network for a long time.\n */\n@Documented\n@Retention(RetentionPolicy.SOURCE)\n@Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, MODULE, PARAMETER, TYPE})\npublic @interface RsDeprecated\n{\n\t/**\n\t * Returns the version of Retroshare in which the annotated element became deprecated.\n\t * The version string is in the same format as the Retroshare release version (for example 0.6.7).\n\t *\n\t * @return the version string\n\t */\n\tString since() default \"\";\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/condition/OnLinuxCondition.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.condition;\n\nimport org.apache.commons.lang3.SystemUtils;\nimport org.springframework.context.annotation.Condition;\nimport org.springframework.context.annotation.ConditionContext;\nimport org.springframework.core.type.AnnotatedTypeMetadata;\n\npublic class OnLinuxCondition implements Condition\n{\n\t@Override\n\tpublic boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata)\n\t{\n\t\treturn SystemUtils.IS_OS_LINUX;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/condition/OnMacCondition.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.condition;\n\nimport org.apache.commons.lang3.SystemUtils;\nimport org.springframework.context.annotation.Condition;\nimport org.springframework.context.annotation.ConditionContext;\nimport org.springframework.core.type.AnnotatedTypeMetadata;\n\npublic class OnMacCondition implements Condition\n{\n\t@Override\n\tpublic boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata)\n\t{\n\t\treturn SystemUtils.IS_OS_MAC;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/condition/OnWindowsCondition.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.condition;\n\nimport org.apache.commons.lang3.SystemUtils;\nimport org.springframework.context.annotation.Condition;\nimport org.springframework.context.annotation.ConditionContext;\nimport org.springframework.core.type.AnnotatedTypeMetadata;\n\npublic class OnWindowsCondition implements Condition\n{\n\t@Override\n\tpublic boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata)\n\t{\n\t\treturn SystemUtils.IS_OS_WINDOWS;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/board/BoardGroupDTO.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.board;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic record BoardGroupDTO(\n\t\tlong id,\n\t\tGxsId gxsId,\n\t\tString name,\n\t\tString description,\n\t\tboolean hasImage,\n\t\tboolean subscribed,\n\t\tboolean external,\n\t\tint visibleMessageCount,\n\t\tInstant lastActivity\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/board/BoardMessageDTO.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.board;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\nimport java.time.Instant;\n\npublic record BoardMessageDTO(\n\t\tlong id,\n\t\tGxsId gxsId,\n\t\tMsgId msgId,\n\t\tlong originalId,\n\t\tlong parentId,\n\t\tGxsId authorGxsId,\n\t\tString authorName,\n\t\tString name,\n\t\tInstant published,\n\t\tString link,\n\t\tString content,\n\t\tboolean hasImage,\n\t\tint imageWidth,\n\t\tint imageHeight,\n\t\tboolean read\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/channel/ChannelFileDTO.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.channel;\n\nimport io.xeres.common.id.Sha1Sum;\nimport jakarta.validation.constraints.NotNull;\n\npublic record ChannelFileDTO(\n\t\tlong size,\n\t\tSha1Sum hash,\n\t\t@NotNull(message = \"Name is mandatory\")\n\t\tString name,\n\t\tString path,\n\t\tint age\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/channel/ChannelGroupDTO.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.channel;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic record ChannelGroupDTO(\n\t\tlong id,\n\t\tGxsId gxsId,\n\t\tString name,\n\t\tString description,\n\t\tboolean hasImage,\n\t\tboolean subscribed,\n\t\tboolean external,\n\t\tint visibleMessageCount,\n\t\tInstant lastActivity\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/channel/ChannelMessageDTO.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.channel;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\n\nimport static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;\n\npublic record ChannelMessageDTO(\n\t\tlong id,\n\t\tGxsId gxsId,\n\t\tMsgId msgId,\n\t\tlong originalId,\n\t\tlong parentId,\n\t\tGxsId authorGxsId,\n\t\tString authorName,\n\t\tString name,\n\t\tInstant published,\n\t\tString content,\n\t\tboolean hasImage,\n\t\tint imageWidth,\n\t\tint imageHeight,\n\t\tboolean hasFiles,\n\t\t@JsonInclude(NON_EMPTY)\n\t\tList<ChannelFileDTO> files,\n\t\tboolean read\n)\n{\n\tpublic ChannelMessageDTO\n\t{\n\t\tif (files == null)\n\t\t{\n\t\t\tfiles = new ArrayList<>();\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (!(o instanceof ChannelMessageDTO that))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\treturn Objects.equals(gxsId, that.gxsId);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hashCode(gxsId);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChannelMessageDTO{\" +\n\t\t\t\t\"gxsId=\" + gxsId +\n\t\t\t\t\", name='\" + name + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/chat/ChatBacklogDTO.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport java.time.Instant;\n\npublic record ChatBacklogDTO(Instant created, boolean own, String message)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/chat/ChatIdentityDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport io.xeres.common.id.GxsId;\n\npublic record ChatIdentityDTO(\n\t\tString nickname,\n\t\tGxsId gxsId,\n\t\tlong identityId\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/chat/ChatRoomBacklogDTO.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic record ChatRoomBacklogDTO(Instant created, GxsId gxsId, String nickname, String message)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/chat/ChatRoomContextDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\npublic record ChatRoomContextDTO(\n\t\tChatRoomsDTO chatRooms,\n\t\tChatIdentityDTO identity\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/chat/ChatRoomDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport io.xeres.common.message.chat.RoomType;\n\npublic record ChatRoomDTO(\n\t\tlong id,\n\t\tString name,\n\t\tRoomType roomType,\n\t\tString topic,\n\t\tint count,\n\t\tboolean isSigned\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/chat/ChatRoomsDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport java.util.List;\n\npublic record ChatRoomsDTO(\n\t\tList<ChatRoomDTO> subscribed,\n\t\tList<ChatRoomDTO> available\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/connection/ConnectionDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.connection;\n\nimport java.time.Instant;\n\n// XXX: missing PeerAddress in the DTO\npublic record ConnectionDTO(\n\t\tlong id,\n\t\tString address,\n\t\tInstant lastConnected,\n\t\tboolean external\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/forum/ForumGroupDTO.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.forum;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic record ForumGroupDTO(\n\t\tlong id,\n\t\tGxsId gxsId,\n\t\tString name,\n\t\tString description,\n\t\tboolean subscribed,\n\t\tboolean external,\n\t\tint visibleMessageCount,\n\t\tInstant lastActivity\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/forum/ForumMessageDTO.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.forum;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\nimport java.time.Instant;\n\npublic record ForumMessageDTO(\n\t\tlong id,\n\t\tGxsId gxsId,\n\t\tMsgId msgId,\n\t\tlong originalId,\n\t\tlong parentId,\n\t\tGxsId authorGxsId,\n\t\tString authorName,\n\t\tString name,\n\t\tInstant published,\n\t\tString content,\n\t\tboolean read\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/identity/IdentityConstants.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.identity;\n\npublic final class IdentityConstants\n{\n\t// Those must be like the profile, because the name is derived from it\n\tpublic static final int NAME_LENGTH_MIN = 2;\n\tpublic static final int NAME_LENGTH_MAX = 30;\n\n\tpublic static final long NO_IDENTITY_ID = 0L;\n\tpublic static final long OWN_IDENTITY_ID = 1L;\n\n\tprivate IdentityConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/identity/IdentityDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.identity;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.identity.Type;\n\nimport java.time.Instant;\nimport java.util.Objects;\n\npublic record IdentityDTO(\n\t\tlong id,\n\t\tString name,\n\t\tGxsId gxsId,\n\t\tInstant updated,\n\t\tType type,\n\t\tboolean hasImage\n)\n{\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (IdentityDTO) o;\n\t\treturn name.equals(that.name) && gxsId.equals(that.gxsId);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(name, gxsId);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"IdentityDTO{\" +\n\t\t\t\t\"name='\" + name + '\\'' +\n\t\t\t\t\", gxsId=\" + gxsId +\n\t\t\t\t\", type=\" + type +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/location/LocationConstants.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.location;\n\npublic final class LocationConstants\n{\n\tpublic static final int NAME_LENGTH_MIN = 1;\n\tpublic static final int NAME_LENGTH_MAX = 64;\n\n\tpublic static final long OWN_LOCATION_ID = 1L;\n\n\tprivate LocationConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/location/LocationDTO.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.location;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.xeres.common.dto.connection.ConnectionDTO;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.location.Availability;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Objects;\n\nimport static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;\n\npublic record LocationDTO(\n\n\t\tlong id,\n\n\t\t@NotNull(message = \"Name is mandatory\")\n\t\t@JsonProperty(\"name\")\n\t\tString name,\n\n\t\t@Size(min = LocationIdentifier.LENGTH, max = LocationIdentifier.LENGTH)\n\t\tbyte @NotNull(message = \"Location identifier is mandatory\") [] locationIdentifier,\n\n\t\tString hostname,\n\n\t\t@JsonInclude(NON_EMPTY)\n\t\tList<ConnectionDTO> connections,\n\n\t\tboolean connected,\n\n\t\tInstant lastConnected,\n\n\t\tAvailability availability,\n\n\t\tString version\n)\n{\n\tpublic LocationDTO\n\t{\n\t\tif (connections == null)\n\t\t{\n\t\t\tconnections = new ArrayList<>();\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (LocationDTO) o;\n\t\treturn name.equals(that.name) && Arrays.equals(locationIdentifier, that.locationIdentifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\tvar result = Objects.hash(name);\n\t\tresult = 31 * result + Arrays.hashCode(locationIdentifier);\n\t\treturn result;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"LocationDTO{\" +\n\t\t\t\t\"name='\" + name + '\\'' +\n\t\t\t\t\", locationIdentifier=\" + Arrays.toString(locationIdentifier) +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/profile/ProfileConstants.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.profile;\n\npublic final class ProfileConstants\n{\n\tpublic static final int NAME_LENGTH_MIN = 2;\n\tpublic static final int NAME_LENGTH_MAX = 30;\n\n\tpublic static final long NO_PROFILE_ID = 0L;\n\tpublic static final long OWN_PROFILE_ID = 1L;\n\n\tprivate ProfileConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/profile/ProfileDTO.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.profile;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.pgp.Trust;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Objects;\n\nimport static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;\nimport static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MAX;\nimport static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MIN;\n\npublic record ProfileDTO(\n\n\t\tlong id,\n\n\t\t@NotNull(message = \"Name is mandatory\")\n\t\t@Size(message = \"Name length must be between \" + NAME_LENGTH_MIN + \" and \" + NAME_LENGTH_MAX + \" characters\", min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX)\n\t\tString name,\n\n\t\tString pgpIdentifier, // a string is used instead of a long because JS is limited to 53-bits\n\n\t\tInstant created,\n\n\t\t@Size(min = ProfileFingerprint.V4_LENGTH, max = ProfileFingerprint.LENGTH)\n\t\t@Schema(example = \"nhgF6ITwm/LLqchhpwJ91KFfAxg=\")\n\t\tbyte[] pgpFingerprint,\n\n\t\tbyte[] pgpPublicKeyData,\n\n\t\tboolean accepted,\n\n\t\tTrust trust,\n\n\t\t@JsonInclude(NON_EMPTY)\n\t\tList<LocationDTO> locations\n)\n{\n\tpublic ProfileDTO\n\t{\n\t\tif (locations == null)\n\t\t{\n\t\t\tlocations = new ArrayList<>();\n\t\t}\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (ProfileDTO) o;\n\t\treturn name.equals(that.name) && Objects.equals(pgpIdentifier, that.pgpIdentifier) && Arrays.equals(pgpFingerprint, that.pgpFingerprint) && Arrays.equals(pgpPublicKeyData, that.pgpPublicKeyData);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\tvar result = Objects.hash(name, pgpIdentifier);\n\t\tresult = 31 * result + Arrays.hashCode(pgpFingerprint);\n\t\tresult = 31 * result + Arrays.hashCode(pgpPublicKeyData);\n\t\treturn result;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ProfileDTO{\" +\n\t\t\t\t\"name='\" + name + '\\'' +\n\t\t\t\t\", pgpIdentifier='\" + pgpIdentifier + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/settings/SettingsDTO.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.settings;\n\npublic record SettingsDTO(\n\t\tString torSocksHost,\n\t\tint torSocksPort,\n\t\tString i2pSocksHost,\n\t\tint i2pSocksPort,\n\t\tboolean upnpEnabled,\n\t\tboolean broadcastDiscoveryEnabled,\n\t\tboolean dhtEnabled,\n\t\tboolean autoStartEnabled,\n\t\tString incomingDirectory,\n\t\tString remotePassword,\n\t\tboolean remoteEnabled,\n\t\tboolean upnpRemoteEnabled,\n\t\tint remotePort\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/share/ShareConstants.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.share;\n\npublic final class ShareConstants\n{\n\tpublic static final int NAME_LENGTH_MIN = 1;\n\tpublic static final int NAME_LENGTH_MAX = 64;\n\n\tpublic static final long INCOMING_SHARE = 1L;\n\n\tprivate ShareConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/dto/share/ShareDTO.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.share;\n\nimport io.xeres.common.pgp.Trust;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport java.time.Instant;\nimport java.util.Objects;\n\nimport static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MAX;\nimport static io.xeres.common.dto.share.ShareConstants.NAME_LENGTH_MIN;\n\npublic record ShareDTO(\n\t\tlong id,\n\n\t\t@NotNull(message = \"Name is mandatory\")\n\t\t@Size(message = \"Name length must be between \" + NAME_LENGTH_MIN + \" and \" + NAME_LENGTH_MAX + \" characters\", min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX)\n\t\tString name,\n\n\t\t@NotNull(message = \"Path is mandatory\")\n\t\t@Size(message = \"Path length must be between 1 and 255 characters\", min = 1, max = 255)\n\t\tString path,\n\n\t\tboolean searchable,\n\n\t\tTrust browsable,\n\n\t\tInstant lastScanned\n)\n{\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar shareDTO = (ShareDTO) o;\n\t\treturn id == shareDTO.id && searchable == shareDTO.searchable && Objects.equals(name, shareDTO.name) && Objects.equals(path, shareDTO.path) && browsable == shareDTO.browsable;\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(name);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ShareDTO{\" +\n\t\t\t\t\"name='\" + name + '\\'' +\n\t\t\t\t\", path='\" + path + '\\'' +\n\t\t\t\t\", searchable=\" + searchable +\n\t\t\t\t\", browsable=\" + browsable +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/events/ConnectWebSocketsEvent.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.events;\n\n/**\n * This event is sent when it's time to perform the WebSockets connections.\n */\npublic record ConnectWebSocketsEvent()\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/events/StartupEvent.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.events;\n\n/**\n * First event that is sent when the application is starting.\n */\npublic record StartupEvent() implements SynchronousEvent\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/events/SynchronousEvent.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.events;\n\n/**\n * This interface marker is applied to events that should be sent synchronously.\n */\npublic interface SynchronousEvent\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/file/FileType.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.file;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\n\nimport java.util.Locale;\nimport java.util.ResourceBundle;\nimport java.util.Set;\n\npublic enum FileType implements I18nEnum\n{\n\tANY(Set.of()),\n\tAUDIO(Set.of(\n\t\t\t\"3ga\", // Adaptive Multi-Rate Audio Codec\n\t\t\t\"8svx\", // Amiga IFF-8SVX File\n\t\t\t\"aac\", // Advanced Audio Coding\n\t\t\t\"ac3\", // Dolby Digital\n\t\t\t\"aif\", // Audio Interchange File Format\n\t\t\t\"aifc\", // Audio Interchange File Format\n\t\t\t\"aiff\", // Audio Interchange File Format\n\t\t\t\"amr\", // Adaptive Multi-Rate Audio Codec\n\t\t\t\"ape\", // Monkey's Audio Lossless Audio File\n\t\t\t\"au\", // Audio File (Sun Microsystems)\n\t\t\t\"aud\", // General Audio File\n\t\t\t\"audio\", // General Audio File\n\t\t\t\"cda\", // CD Audio Track\n\t\t\t\"dmf\", // D-Lusion Music Format Module\n\t\t\t\"dsm\", // Digital Sound Interface Kit Module\n\t\t\t\"dts\", // DTS Encoded Audio File\n\t\t\t\"far\", // Farandole Composer Module\n\t\t\t\"flac\", // Free Lossless Audio Codec File\n\t\t\t\"it\", // Impulse Tracker Module\n\t\t\t\"m1a\", // MPEG-1 Audio File\n\t\t\t\"m2a\", // MPEG-2 Audio File\n\t\t\t\"m3u\", // Multimedia Playlist File\n\t\t\t\"m3u8\", // Multimedia Playlist File (UTF-8)\n\t\t\t\"m4a\", // MPEG-4 Audio File\n\t\t\t\"mdl\", // DigiTrakker Module\n\t\t\t\"med\", // OctaMED Module\n\t\t\t\"mid\", // MIDI File\n\t\t\t\"midi\", // MIDI File\n\t\t\t\"mka\", // Matroska Audio File\n\t\t\t\"mod\", // Amiga Music Module (SoundTracker, ProTracker, etc...)\n\t\t\t\"mp1\", // MPEG Audio Layer I File\n\t\t\t\"mp2\", // MPEG Audio Layer II File\n\t\t\t\"mp3\", // MPEG Audio Layer III File\n\t\t\t\"mpa\", // MPEG Audio File\n\t\t\t\"mpc\", // Musepack\n\t\t\t\"mtm\", // MultiTracker Module\n\t\t\t\"ogg\", // Ogg Vorbis Audio File\n\t\t\t\"psm\", // ProTracker Studio Module\n\t\t\t\"ptm\", // PolyTracker Module\n\t\t\t\"ra\", // Real Audio File\n\t\t\t\"ram\", // Real Audio Meta File\n\t\t\t\"rmi\", // RIFF MIDI Music File\n\t\t\t\"s3m\", // ScreamTracker 3 Module\n\t\t\t\"snd\", // Audio File (Sun Microsystems)\n\t\t\t\"stm\", // ScreamTracker 2 Module\n\t\t\t\"umx\", // Unreal Engine 1 Music Format\n\t\t\t\"wav\", // Waveform Audio File Format\n\t\t\t\"weba\", // WebM Audio\n\t\t\t\"wma\", // Windows Media Audio\n\t\t\t\"xm\" // FastTracker 2 Extended Module\n\t)),\n\tARCHIVE(Set.of(\n\t\t\t\"7z\", // 7-Zip\n\t\t\t\"ace\", // WinAce\n\t\t\t\"adf\", // Amiga Disk File\n\t\t\t\"adz\", // Amiga Disk File, GZipped\n\t\t\t\"alz\", // ALZip\n\t\t\t\"arc\", // ARC\n\t\t\t\"arj\", // ARJ\n\t\t\t\"bin\", // CD Image\n\t\t\t\"br\", // Brotli\n\t\t\t\"bwa\", // BlindWrite Disk Information File\n\t\t\t\"bwi\", // BlindWrite CD/DVD Disc Image\n\t\t\t\"bws\", // BlindWrite Sub Code File\n\t\t\t\"bwt\", // BlindWrite 4 Disk Image\n\t\t\t\"bz2\", // Bzip\n\t\t\t\"cab\", // Microsoft's Cabinet File\n\t\t\t\"ccd\", // CloneCD Disk Image\n\t\t\t\"cif\", // Easy CD Creator\n\t\t\t\"cue\", // CDWrite Cue Sheet File\n\t\t\t\"dmg\", // MacOS Disk Image\n\t\t\t\"dms\", // The DiskMasher System Amiga Disk Archiver\n\t\t\t\"dsk\", // Floppy Disk Archiving\n\t\t\t\"gb\", // Game Boy ROMs\n\t\t\t\"gba\", // GBA ROMs\n\t\t\t\"gz\", // GNU Zip\n\t\t\t\"hdf\", // UAE HardFile\n\t\t\t\"hqx\", // BinHex 4.0\n\t\t\t\"img\", // Disk Image Data File\n\t\t\t\"iso\", // Disc Image File\n\t\t\t\"lha\", // LHA\n\t\t\t\"lzh\", // LZH\n\t\t\t\"mdf\", // Media Disc Image File\n\t\t\t\"mds\", // Daemon Tools\n\t\t\t\"nrg\", // Nero Burning Rom CD/DVD Image File\n\t\t\t\"pak\", // PAK\n\t\t\t\"par\", // Parchive Index\n\t\t\t\"par2\", // Parchive 2 Index\n\t\t\t\"rar\", // WinRAR\n\t\t\t\"ratdvd\", // RatDVD Disk Image\n\t\t\t\"rom\", // Emulator ROMs\n\t\t\t\"sea\", // StuffIt Archive\n\t\t\t\"sfc\", // SNES ROMs\n\t\t\t\"sit\", // StuffIt Archive\n\t\t\t\"sitx\", // Stuffit X Archive\n\t\t\t\"tar\", // Tape Archive (Unix)\n\t\t\t\"tbz2\", // BZip2-ed Tar File\n\t\t\t\"tgz\", // GZipped Tar File\n\t\t\t\"toast\", // Toast Disc Image\n\t\t\t\"vhdx\", // Hyper-V Virtual Hard Disk\n\t\t\t\"z\", // Unix Compress\n\t\t\t\"zip\", // Zipped Archive\n\t\t\t\"zst\" // Zstandard\n\t)),\n\tDOCUMENT(Set.of(\n\t\t\t\"adoc\", // AsciiDoc\n\t\t\t\"asc\", // Text File\n\t\t\t\"cbr\", // Comic Book RAR Archive\n\t\t\t\"cbz\", // Comic Book ZIP Archive\n\t\t\t\"chm\", // Microsoft's Compiled HTML\n\t\t\t\"css\", // Cascading Style Sheet\n\t\t\t\"csv\", // Comma Separated Values\n\t\t\t\"diz\", // Description in Zip file\n\t\t\t\"doc\", // Microsoft Word Document\n\t\t\t\"dot\", // Microsoft Word Template\n\t\t\t\"epub\", // E-Books\n\t\t\t\"hlp\", // Microsoft Help File\n\t\t\t\"htm\", // HTML File\n\t\t\t\"html\", // HTML File\n\t\t\t\"log\", // Log File\n\t\t\t\"md\", // Markdown File\n\t\t\t\"msg\", // Outlook Mail Message File\n\t\t\t\"nfo\", // Warez Information File\n\t\t\t\"ods\", // Open Document Spreadsheet\n\t\t\t\"odt\", // Open Document Document\n\t\t\t\"ott\", // Open Document Template\n\t\t\t\"pdf\", // Portable Document Format\n\t\t\t\"pps\", // PowerPoint Slide Show\n\t\t\t\"ppt\", // PowerPoint Template\n\t\t\t\"ps\", // PostScript File\n\t\t\t\"rtf\", // Rich Text Format File\n\t\t\t\"text\", // Text File\n\t\t\t\"txt\", // Text File\n\t\t\t\"wpd\", // WordPerfect\n\t\t\t\"wps\", // Microsoft Works\n\t\t\t\"wri\", // Windows Write\n\t\t\t\"xls\", // Microsoft Excel Spreadsheet\n\t\t\t\"xml\" // eXtended Markup Language\n\t)),\n\tPICTURE(Set.of(\n\t\t\t\"3dm\", // OpenNURBS Initiative 3D Model\n\t\t\t\"3dmf\", // QuickDraw 3D Metafile\n\t\t\t\"ai\", // Adobe Illustrator\n\t\t\t\"avif\", // AV1 Image File Format\n\t\t\t\"bmp\", // Bitmap Image File\n\t\t\t\"drw\", // CADS Planner Drawing\n\t\t\t\"dxf\", // AutoCAD\n\t\t\t\"emf\", // Enhanced Windows Metafile\n\t\t\t\"eps\", // Encapsulated PostScript\n\t\t\t\"gif\", // Graphical Interchange Format File\n\t\t\t\"heic\", // High Efficiency Image Format\n\t\t\t\"heif\", // High Efficiency Image Format\n\t\t\t\"ico\", // Windows Icon file\n\t\t\t\"iff\", // Interchange File Format (Amiga)\n\t\t\t\"indd\", // Adobe InDesign\n\t\t\t\"jfif\", // JPEG File Interchange Format\n\t\t\t\"jpe\", // JPEG Image File\n\t\t\t\"jpeg\", // JPEG Image File\n\t\t\t\"jpg\", // JPEG Image File\n\t\t\t\"lbm\", // IFF Interleaved Bitmap\n\t\t\t\"mng\", // Multiple-Image Network Graphics Bitmap\n\t\t\t\"pct\", // PICT Picture File\n\t\t\t\"pcx\", // Paintbrush Bitmap Image File\n\t\t\t\"pgm\", // Portable GrayMap Bitmap File\n\t\t\t\"pic\", // PICT Picture File\n\t\t\t\"pict\", // PICT Picture File\n\t\t\t\"pix\", // Alias PIX Bitmap\n\t\t\t\"png\", // Portable Network Graphic\n\t\t\t\"psd\", // Photoshop Document\n\t\t\t\"psp\", // Paint Shop Pro Image File\n\t\t\t\"qxd\", // QuarkXPress\n\t\t\t\"qxp\", // QuarkXPress\n\t\t\t\"rgb\", // ColorViewSquash Bitmap\n\t\t\t\"sgi\", // Silicon Graphics Bitmap\n\t\t\t\"svg\", // Scalable Vector Graphics\n\t\t\t\"tga\", // Targa Graphic\n\t\t\t\"tif\", // Tagged Image File\n\t\t\t\"tiff\", // Tagged Image File\n\t\t\t\"webp\", // WebP Image File\n\t\t\t\"wmf\", // Windows Metafile\n\t\t\t\"wmp\", // Windows Media Photo File\n\t\t\t\"xbm\", // X Bitmap File\n\t\t\t\"xcf\", // GIMP Image\n\t\t\t\"xif\" // ScanSoft Pagis Extended Image Format File\n\t)),\n\tPROGRAM(Set.of(\n\t\t\t\"apk\", // Android Package\n\t\t\t\"app\", // MacOS Application Bundle\n\t\t\t\"appimage\", // AppImage\n\t\t\t\"cmd\", // Command File\n\t\t\t\"com\", // DOS executable\n\t\t\t\"deb\", // Debian Package\n\t\t\t\"exe\", // Executable File\n\t\t\t\"flatpak\", // Linux Flatpak Application Bundle\n\t\t\t\"jar\", // Java Archive\n\t\t\t\"msi\", // Microsoft Installer\n\t\t\t\"pkg\", // MacOS Installer\n\t\t\t\"rpm\", // RedHat Package\n\t\t\t\"snap\", // Canonical Snap Linux\n\t\t\t\"xpi\" // Mozilla Installer\n\t)),\n\tVIDEO(Set.of(\n\t\t\t\"3g2\", // 3GPP Multimedia File\n\t\t\t\"3gp\", // 3GPP Multimedia File\n\t\t\t\"3gp2\", // 3GPP Multimedia File\n\t\t\t\"3gpp\", // 3GPP Multimedia File\n\t\t\t\"amv\", // Anime Music Video File\n\t\t\t\"asf\", // Advanced Systems Format File\n\t\t\t\"asx\", // Advanced Stream Redirector File\n\t\t\t\"avi\", // Audio Video Interleave File\n\t\t\t\"bik\", // BINK Video File\n\t\t\t\"divx\", // DivX Movie File\n\t\t\t\"dvr-ms\", // Microsoft Digital Video Recording\n\t\t\t\"flc\", // FLIC Video File\n\t\t\t\"fli\", // FLIC Video File\n\t\t\t\"flic\", // FLIC Video File\n\t\t\t\"flv\", // Flash Video File\n\t\t\t\"hdmov\", // High-Definition QuickTime Movie\n\t\t\t\"ifo\", // DVD-Video Disc Information File\n\t\t\t\"m1v\", // MPEG-1 Video File\n\t\t\t\"m2t\", // MPEG-2 Video Transport Stream\n\t\t\t\"m2ts\", // MPEG-2 Video Transport Stream\n\t\t\t\"m2v\", // MPEG-2 Video File\n\t\t\t\"m4b\", // MPEG-4 Video File\n\t\t\t\"m4v\", // MPEG-4 Video File\n\t\t\t\"mkv\", // Matroska Video File\n\t\t\t\"mov\", // QuickTime Movie File\n\t\t\t\"movie\", // QuickTime Movie File\n\t\t\t\"mp1v\", // MPEG-1 Video File\n\t\t\t\"mp2v\", // MPEG-2 Video File\n\t\t\t\"mp4\", // MPEG-4 Video File\n\t\t\t\"mpe\", // MPEG Video File\n\t\t\t\"mpeg\", // MPEG Video File\n\t\t\t\"mpg\", // MPEG Video File\n\t\t\t\"mpv\", // MPEG Video File\n\t\t\t\"mpv1\", // MPEG-1 Video File\n\t\t\t\"mpv2\", // MPEG-2 Video File\n\t\t\t\"ogm\", // Ogg Media File\n\t\t\t\"pva\", // MPEG Video File\n\t\t\t\"qt\", // QuickTime Movie\n\t\t\t\"rm\", // Real Media File\n\t\t\t\"rmm\", // Real Media File\n\t\t\t\"rmvb\", // Real Video Variable Bit Rate File\n\t\t\t\"rv\", // Real Video File\n\t\t\t\"smil\", // SMIL Presentation File\n\t\t\t\"smk\", // Smacker Compressed Movie File\n\t\t\t\"swf\", // Macromedia Flash Movie\n\t\t\t\"tp\", // Video Transport Stream File\n\t\t\t\"ts\", // Video Transport Stream File\n\t\t\t\"vid\", // General Video File\n\t\t\t\"video\", // General Video File\n\t\t\t\"vob\", // DVD Video Object File\n\t\t\t\"vp6\", // TrueMotion VP6 Video File\n\t\t\t\"webm\", // WebM\n\t\t\t\"wm\", // Windows Media Video File\n\t\t\t\"wmv\", // Windows Media Video File\n\t\t\t\"xvid\" // Xvid-Encoded Video File\n\t)),\n\tSUBTITLES(Set.of(\n\t\t\t\"srt\", // SubRip\n\t\t\t\"sub\"\n\t)),\n\tCOLLECTION(Set.of(\n\t\t\t\"emulecollection\", // Emule\n\t\t\t\"rscollection\", // Retroshare\n\t\t\t\"torrent\" // Torrent\n\t)),\n\tDIRECTORY(Set.of());\n\n\tprivate final Set<String> extensions;\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tFileType(Set<String> extensions)\n\t{\n\t\tthis.extensions = extensions;\n\t}\n\n\tpublic Set<String> getExtensions()\n\t{\n\t\treturn extensions;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn bundle.getString(getMessageKey(this));\n\t}\n\n\tpublic static FileType getTypeByExtension(String filename)\n\t{\n\t\tvar index = filename.lastIndexOf(\".\");\n\t\tif (index == -1)\n\t\t{\n\t\t\treturn ANY;\n\t\t}\n\t\tvar extension = filename.substring(index + 1);\n\t\tif (extension.isEmpty())\n\t\t{\n\t\t\treturn ANY;\n\t\t}\n\t\tfor (var value : values())\n\t\t{\n\t\t\tif (value.getExtensions().contains(extension.toLowerCase(Locale.ROOT)))\n\t\t\t{\n\t\t\t\treturn value;\n\t\t\t}\n\t\t}\n\t\treturn ANY;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/geoip/Country.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.geoip;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\n\nimport java.util.ResourceBundle;\n\n/**\n * The list of country codes.\n * @see <a href=\"https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2\">Wikipedia ISO-3166-1 Alpha 2</a>\n */\npublic enum Country implements I18nEnum\n{\n\tAF,\n\tAL,\n\tDZ,\n\tAS,\n\tAD,\n\tAO,\n\tAI,\n\tAQ,\n\tAG,\n\tAR,\n\tAM,\n\tAW,\n\tAU,\n\tAT,\n\tAZ,\n\tBS,\n\tBH,\n\tBD,\n\tBB,\n\tBY,\n\tBE,\n\tBZ,\n\tBJ,\n\tBM,\n\tBT,\n\tBO,\n\tBA,\n\tBW,\n\tBV,\n\tBR,\n\tIO,\n\tBN,\n\tBG,\n\tBF,\n\tBI,\n\tKH,\n\tCM,\n\tCA,\n\tCV,\n\tKY,\n\tCF,\n\tTD,\n\tCL,\n\tCN,\n\tCX,\n\tCC,\n\tCO,\n\tKM,\n\tCG,\n\tCD,\n\tCK,\n\tCR,\n\tCI,\n\tHR,\n\tCU,\n\tCY,\n\tCZ,\n\tDK,\n\tDJ,\n\tDM,\n\tDO,\n\tEC,\n\tEG,\n\tSV,\n\tGQ,\n\tER,\n\tEE,\n\tET,\n\tFK,\n\tFO,\n\tFJ,\n\tFI,\n\tFR,\n\tGF,\n\tPF,\n\tTF,\n\tGA,\n\tGM,\n\tGE,\n\tDE,\n\tGH,\n\tGI,\n\tGR,\n\tGL,\n\tGD,\n\tGP,\n\tGU,\n\tGT,\n\tGG,\n\tGN,\n\tGW,\n\tGY,\n\tHT,\n\tHM,\n\tVA,\n\tHN,\n\tHK,\n\tHU,\n\tIS,\n\tIN,\n\tID,\n\tIR,\n\tIQ,\n\tIE,\n\tIM,\n\tIL,\n\tIT,\n\tJM,\n\tJP,\n\tJE,\n\tJO,\n\tKZ,\n\tKE,\n\tKI,\n\tKP,\n\tKR,\n\tKW,\n\tKG,\n\tLA,\n\tLV,\n\tLB,\n\tLS,\n\tLR,\n\tLY,\n\tLI,\n\tLT,\n\tLU,\n\tMO,\n\tMK,\n\tMG,\n\tMW,\n\tMY,\n\tMV,\n\tML,\n\tMT,\n\tMH,\n\tMQ,\n\tMR,\n\tMU,\n\tYT,\n\tMX,\n\tFM,\n\tMD,\n\tMC,\n\tMN,\n\tME,\n\tMS,\n\tMA,\n\tMZ,\n\tMM,\n\tNA,\n\tNR,\n\tNP,\n\tNL,\n\tAN,\n\tNC,\n\tNZ,\n\tNI,\n\tNE,\n\tNG,\n\tNU,\n\tNF,\n\tMP,\n\tNO,\n\tOM,\n\tPK,\n\tPW,\n\tPS,\n\tPA,\n\tPG,\n\tPY,\n\tPE,\n\tPH,\n\tPN,\n\tPL,\n\tPT,\n\tPR,\n\tQA,\n\tRE,\n\tRO,\n\tRU,\n\tRW,\n\tSH,\n\tKN,\n\tLC,\n\tPM,\n\tVC,\n\tWS,\n\tSM,\n\tST,\n\tSA,\n\tSN,\n\tRS,\n\tSC,\n\tSL,\n\tSG,\n\tSK,\n\tSI,\n\tSB,\n\tSO,\n\tZA,\n\tGS,\n\tSS,\n\tES,\n\tLK,\n\tSD,\n\tSR,\n\tSJ,\n\tSZ,\n\tSE,\n\tCH,\n\tSY,\n\tTW,\n\tTJ,\n\tTZ,\n\tTH,\n\tTL,\n\tTG,\n\tTK,\n\tTO,\n\tTT,\n\tTN,\n\tTR,\n\tTM,\n\tTC,\n\tTV,\n\tUG,\n\tUA,\n\tAE,\n\tGB,\n\tUS,\n\tUM,\n\tUY,\n\tUZ,\n\tVU,\n\tVE,\n\tVN,\n\tVG,\n\tVI,\n\tWF,\n\tEH,\n\tYE,\n\tZM,\n\tZW,\n\tLAN,\n\tTOR,\n\tI2P;\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn bundle.getString(getMessageKey(this));\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/gxs/GxsGroupConstants.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.gxs;\n\npublic final class GxsGroupConstants\n{\n\tpublic static final int IMAGE_SIDE_SIZE = 128; // GXS groups are squared\n\n\tprivate GxsGroupConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/i18n/I18nEnum.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.i18n;\n\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.Locale;\n\npublic interface I18nEnum\n{\n\t/**\n\t * Returns the message key for an enum. The format is:\n\t * {@code enum.(<enclosing class>.)<enum name>.<enum value>} all in lower case.\n\t *\n\t * @param e the enum\n\t * @return the enum message key\n\t */\n\tdefault String getMessageKey(Enum<?> e)\n\t{\n\t\tvar enumClass = e.getClass();\n\t\tvar sb = new StringBuilder(\"enum.\");\n\t\tif (enumClass.getEnclosingClass() != null)\n\t\t{\n\t\t\tsb.append(getAsKebabCase(enumClass.getEnclosingClass().getSimpleName()));\n\t\t\tsb.append(\".\");\n\t\t}\n\t\tsb.append(getAsKebabCase(enumClass.getSimpleName()));\n\t\tsb.append(\".\");\n\t\tsb.append(e.name().toLowerCase(Locale.ROOT));\n\t\treturn sb.toString();\n\t}\n\n\tprivate static String getAsKebabCase(String input)\n\t{\n\t\treturn StringUtils.join(StringUtils.splitByCharacterTypeCamelCase(input), \"-\").toLowerCase(Locale.ROOT);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/i18n/I18nUtils.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.i18n;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.IllformedLocaleException;\nimport java.util.Locale;\nimport java.util.ResourceBundle;\n\npublic final class I18nUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(I18nUtils.class);\n\n\tprivate static final String BUNDLE = \"i18n.messages\";\n\n\tprivate static final ResourceBundle RESOURCE_BUNDLE_INSTANCE = createBundle();\n\n\tprivate I18nUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Gets the ResourceBundle.\n\t * <p>\n\t * Note: prefer using the ResourceBundle bean for spring components.\n\t *\n\t * @return the resource bundle\n\t */\n\tpublic static ResourceBundle getBundle()\n\t{\n\t\treturn RESOURCE_BUNDLE_INSTANCE;\n\t}\n\n\tprivate static ResourceBundle createBundle()\n\t{\n\t\tvar envLanguage = System.getenv(\"XERES_LANGUAGE\");\n\t\tif (envLanguage != null)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tLocale.setDefault(new Locale.Builder().setLanguage(envLanguage).build());\n\t\t\t}\n\t\t\tcatch (IllformedLocaleException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Locale {} is ill formed: {}\", envLanguage, e.getMessage());\n\t\t\t}\n\t\t}\n\t\treturn ResourceBundle.getBundle(BUNDLE);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/GxsId.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport jakarta.persistence.Embeddable;\n\nimport java.util.Arrays;\nimport java.util.Objects;\n\n@Embeddable\npublic class GxsId implements Identifier, Comparable<GxsId>\n{\n\tpublic static final int LENGTH = 16;\n\n\tpublic static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR\n\n\tprivate byte[] identifier;\n\n\tpublic GxsId()\n\t{\n\n\t}\n\n\tpublic GxsId(byte[] identifier)\n\t{\n\t\tObjects.requireNonNull(identifier, \"Null identifier\");\n\t\tif (identifier.length != LENGTH)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Bad identifier length, expected \" + LENGTH + \", got \" + identifier.length);\n\t\t}\n\t\tthis.identifier = identifier;\n\t}\n\n\t/**\n\t * Creates a {@link GxsId} from a string.\n\t *\n\t * @param from a string representing the GxsId in hexadecimal form (lowercase, no prefix)\n\t * @return the GxsId or an empty GxsId if the string was invalid\n\t */\n\tpublic static GxsId fromString(String from)\n\t{\n\t\treturn new GxsId(Identifier.parseString(from, LENGTH));\n\t}\n\n\t@Override\n\tpublic byte[] getBytes()\n\t{\n\t\treturn identifier;\n\t}\n\n\t// This is used for serialization (for example passing a GxsId in a STOMP message)\n\tpublic void setBytes(byte[] identifier)\n\t{\n\t\tthis.identifier = identifier;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic int getLength()\n\t{\n\t\treturn LENGTH;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic byte[] getNullIdentifier()\n\t{\n\t\treturn NULL_IDENTIFIER;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar gxsId = (GxsId) o;\n\t\treturn Arrays.equals(identifier, gxsId.identifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Arrays.hashCode(identifier);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn Id.toString(identifier);\n\t}\n\n\t@Override\n\tpublic int compareTo(GxsId o)\n\t{\n\t\treturn Arrays.compareUnsigned(identifier, o.identifier);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/Id.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport org.apache.commons.lang3.ArrayUtils;\n\nimport java.util.HexFormat;\nimport java.util.Locale;\n\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\n\n/**\n * Contains ID conversion and representation methods. Used for locations, PGP identifiers, identities and so on.\n */\npublic final class Id\n{\n\tprivate Id()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Converts a series of bytes into its hexadecimal representation. For example if the\n\t * byte array contains 2 bytes like 28 then 3, the result is \"1c03\".\n\t *\n\t * @param id the id as a stream of bytes\n\t * @return the lowercase hexadecimal representation of those bytes, without any prefix or an empty string if the id is null or empty\n\t */\n\tpublic static String toString(byte[] id)\n\t{\n\t\tif (ArrayUtils.isEmpty(id))\n\t\t{\n\t\t\treturn \"\";\n\t\t}\n\n\t\tvar sb = new StringBuilder(id.length * 2);\n\n\t\tfor (var b : id)\n\t\t{\n\t\t\tHexFormat.of().toHexDigits(sb, b);\n\t\t}\n\t\treturn sb.toString();\n\t}\n\n\t/**\n\t * Converts a hexadecimal string into an array of bytes. For example\n\t * if the input contains \"1c03\", then the result is an array of 2 bytes with 28 then 3.\n\t *\n\t * @param id the values as a lowercase hexadecimal series of bytes, without prefix\n\t * @return an array of bytes containing those values or an empty array if the id is null or empty\n\t * @throws NumberFormatException if this is not a hexadecimal number\n\t */\n\tpublic static byte[] toBytes(String id)\n\t{\n\t\tif (isEmpty(id))\n\t\t{\n\t\t\treturn new byte[0];\n\t\t}\n\n\t\tif (id.length() % 2 != 0)\n\t\t{\n\t\t\tthrow new NumberFormatException(\"Odd number of bytes\");\n\t\t}\n\n\t\tvar out = new byte[id.length() / 2];\n\n\t\tfor (var i = 0; i < out.length; i++)\n\t\t{\n\t\t\tvar index = i * 2;\n\t\t\tout[i] = (byte) Integer.parseUnsignedInt(id.substring(index, index + 2), 16);\n\t\t}\n\t\treturn out;\n\t}\n\n\t/**\n\t * Converts an id into its hexadecimal representation.\n\t *\n\t * @param id the id\n\t * @return a hexadecimal uppercase representation of the id, without prefix\n\t */\n\tpublic static String toString(long id)\n\t{\n\t\treturn toStringLowerCase(id).toUpperCase(Locale.ROOT);\n\t}\n\n\t/**\n\t * Converts an id into its hexadecimal representation.\n\t *\n\t * @param id the id\n\t * @return a hexadecimal lowercase representation of the id, without prefix\n\t */\n\tpublic static String toStringLowerCase(long id)\n\t{\n\t\treturn HexFormat.of().toHexDigits(id, 16);\n\t}\n\n\t/**\n\t * Converts an identifier into its hexadecimal representation.\n\t *\n\t * @param identifier the identifier\n\t * @return a hexadecimal lowercase representation of the identifier, without prefix, or an empty string if the identifier is empty\n\t */\n\tpublic static String toString(Identifier identifier)\n\t{\n\t\tif (identifier == null)\n\t\t{\n\t\t\treturn \"\";\n\t\t}\n\t\treturn toString(identifier.getBytes());\n\t}\n\n\t/**\n\t * Converts a string containing a hexadecimal ASCII representation of bytes into an array of\n\t * the corresponding byte values. For example, if the string contains \"3133\" (0x31 ('1') and 0x33 ('3'))\n\t * which represents 0x13, the result is an array of bytes which is { 0x13 }.\n\t *\n\t * @param id a string of hexadecimal ASCII values\n\t * @return an array of corresponding values\n\t */\n\tpublic static byte[] asciiStringToBytes(String id)\n\t{\n\t\treturn asciiToBytes(id.getBytes());\n\t}\n\n\t/**\n\t * Converts an array containing a hexadecimal ASCII representation of bytes into an array of\n\t * the corresponding byte values. For example, if the array contains 0x31 ('1') and 0x33 ('3')\n\t * which represents 0x13, the result is an array of bytes which is { 0x13 }.\n\t *\n\t * @param id an array of hexadecimal ASCII values\n\t * @return an array of corresponding values\n\t */\n\tpublic static byte[] asciiToBytes(byte[] id)\n\t{\n\t\tif (ArrayUtils.isEmpty(id))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"id is null or empty\");\n\t\t}\n\n\t\tif (id.length % 2 == 1)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"id is not even\");\n\t\t}\n\n\t\tvar result = new byte[id.length / 2];\n\t\tbyte number;\n\t\tvar accumulator = 0;\n\n\t\tfor (var i = 0; i < id.length; i++)\n\t\t{\n\t\t\tnumber = id[i];\n\n\t\t\tif (number >= 'a')\n\t\t\t{\n\t\t\t\tif (number > 'f')\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"id has an invalid ascii value: \" + number);\n\t\t\t\t}\n\t\t\t\tnumber -= 'a';\n\t\t\t\tnumber += (byte) 10;\n\t\t\t}\n\t\t\telse if (number >= '0')\n\t\t\t{\n\t\t\t\tnumber -= '0';\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"id has an invalid ascii value: \" + number);\n\t\t\t}\n\n\t\t\tif (i % 2 == 1)\n\t\t\t{\n\t\t\t\tresult[i / 2] = (byte) (accumulator * 16 + number);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\taccumulator = number;\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * Converts an identifier to its ASCII representation. For example if the identifier is 0x12, then its\n\t * ASCII representation will be { 0x31, 0x32 } ('1' and '2').\n\t *\n\t * @param identifier an identifier\n\t * @return the byte array containing the ASCII values of each number of the identifier. The array is twice as long as the input\n\t */\n\tpublic static byte[] toAsciiBytes(Identifier identifier)\n\t{\n\t\treturn Id.toString(identifier).getBytes();\n\t}\n\n\t/**\n\t * Same as {@link #toAsciiBytes(Identifier)} but in upper case.\n\t *\n\t * @param identifier an identifier\n\t * @return the byte array containing the ASCII values of each number of the identifier in upper case. The array\n\t * is twice as long as the input\n\t */\n\tpublic static byte[] toAsciiBytesUpperCase(Identifier identifier)\n\t{\n\t\treturn Id.toString(identifier).toUpperCase(Locale.ROOT).getBytes();\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/Identifier.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\n\nimport java.util.Arrays;\n\n/**\n * Interface that represents an identifier of an object in the Retroshare protocol that doesn't fit\n * in a primitive type.\n * <br>\n * Note that, unlike Retroshare, there's no identifier full of zeroes, they are null instead.\n */\npublic interface Identifier\n{\n\tString LENGTH_FIELD_NAME = \"LENGTH\";\n\tString NULL_FIELD_NAME = \"NULL_IDENTIFIER\";\n\n\t/**\n\t * Gets a byte representation of the identifier.\n\t *\n\t * @return an array of bytes containing the identifier\n\t */\n\tbyte[] getBytes();\n\n\t/**\n\t * Gets how many bytes are needed to store the identifier.\n\t *\n\t * @return the length of the identifier\n\t */\n\tint getLength();\n\n\t/**\n\t * Gets the representation of the identifier. To be used every time the identity is needed\n\t * as a string (UI, headers, etc...).\n\t *\n\t * @return a string representation\n\t */\n\t@Override\n\tString toString();\n\n\t@JsonIgnore\n\tdefault byte[] getNullIdentifier()\n\t{\n\t\treturn createNullIdentifier(getLength());\n\t}\n\n\tdefault boolean isNullIdentifier()\n\t{\n\t\treturn Arrays.equals(getNullIdentifier(), getBytes());\n\t}\n\n\tstatic byte[] createNullIdentifier(int length)\n\t{\n\t\tvar a = new byte[length];\n\t\tArrays.fill(a, (byte) 0);\n\t\treturn a;\n\t}\n\n\tstatic byte[] parseString(String s, int length)\n\t{\n\t\tbyte[] bytes;\n\t\tif (s == null || s.length() != length * 2)\n\t\t{\n\t\t\tbytes = createNullIdentifier(length);\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tbytes = Id.toBytes(s);\n\t\t\t}\n\t\t\tcatch (NumberFormatException _)\n\t\t\t{\n\t\t\t\tbytes = createNullIdentifier(length);\n\t\t\t}\n\t\t}\n\t\treturn bytes;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/LocationIdentifier.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport jakarta.persistence.Embeddable;\n\nimport java.util.Arrays;\nimport java.util.Objects;\n\n\n@Embeddable\npublic class LocationIdentifier implements Identifier, Comparable<LocationIdentifier>\n{\n\tpublic static final int LENGTH = 16;\n\n\tpublic static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR\n\n\tprivate byte[] identifier;\n\n\tpublic LocationIdentifier()\n\t{\n\n\t}\n\n\tpublic LocationIdentifier(byte[] identifier)\n\t{\n\t\tObjects.requireNonNull(identifier, \"Null identifier\");\n\t\tif (identifier.length != LENGTH)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Bad identifier length, expected \" + LENGTH + \", got \" + identifier.length);\n\t\t}\n\t\tthis.identifier = identifier;\n\t}\n\n\t/**\n\t * Creates a {@link LocationIdentifier} from a string.\n\t *\n\t * @param from the string representing the Location identifier in hexadecimal form (lowercase, no prefix)\n\t * @return the LocationIdentifier or an empty LocationIdentifier if the string was invalid\n\t */\n\tpublic static LocationIdentifier fromString(String from)\n\t{\n\t\treturn new LocationIdentifier(Identifier.parseString(from, LENGTH));\n\t}\n\n\t@Override\n\tpublic byte[] getBytes()\n\t{\n\t\treturn identifier;\n\t}\n\n\t// This is used for serialization (for example passing a GxsId in a STOMP message)\n\tpublic void setBytes(byte[] identifier)\n\t{\n\t\tthis.identifier = identifier;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic int getLength()\n\t{\n\t\treturn LENGTH;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic byte[] getNullIdentifier()\n\t{\n\t\treturn NULL_IDENTIFIER;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (LocationIdentifier) o;\n\t\treturn Arrays.equals(identifier, that.identifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Arrays.hashCode(identifier);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn Id.toString(identifier);\n\t}\n\n\t@Override\n\tpublic int compareTo(LocationIdentifier o)\n\t{\n\t\treturn Arrays.compare(identifier, o.identifier);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/MsgId.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport jakarta.persistence.Embeddable;\n\nimport java.util.Arrays;\nimport java.util.Objects;\n\n@Embeddable\npublic class MsgId implements Identifier, Comparable<MsgId>\n{\n\tpublic static final int LENGTH = 20;\n\n\tpublic static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR\n\n\tprivate byte[] identifier;\n\n\tpublic MsgId()\n\t{\n\n\t}\n\n\tpublic MsgId(byte[] identifier)\n\t{\n\t\tObjects.requireNonNull(identifier, \"Null identifier\");\n\t\tif (identifier.length != LENGTH)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Bad identifier length, expected \" + LENGTH + \", got \" + identifier.length);\n\t\t}\n\t\tthis.identifier = identifier;\n\t}\n\n\t/**\n\t * Creates a {@link MsgId} from a string.\n\t *\n\t * @param from a string representing the MsgId in hexadecimal form (lowercase, no prefix)\n\t * @return the MsgId or an empty MsgId if the string was invalid\n\t */\n\tpublic static MsgId fromString(String from)\n\t{\n\t\treturn new MsgId(Identifier.parseString(from, LENGTH));\n\t}\n\n\t@Override\n\tpublic byte[] getBytes()\n\t{\n\t\treturn identifier;\n\t}\n\n\t// This is used for serialization (for example passing a GxsId in a STOMP message)\n\tpublic void setBytes(byte[] identifier)\n\t{\n\t\tthis.identifier = identifier;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic int getLength()\n\t{\n\t\treturn LENGTH;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic byte[] getNullIdentifier()\n\t{\n\t\treturn NULL_IDENTIFIER;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar msgId = (MsgId) o;\n\t\treturn Arrays.equals(identifier, msgId.identifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Arrays.hashCode(identifier);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn Id.toString(identifier);\n\t}\n\n\t@Override\n\tpublic int compareTo(MsgId o)\n\t{\n\t\treturn Arrays.compare(identifier, o.identifier);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/ProfileFingerprint.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport jakarta.persistence.Embeddable;\n\nimport java.util.Arrays;\nimport java.util.Objects;\n\n@Embeddable\npublic class ProfileFingerprint implements Identifier\n{\n\tpublic static final int V4_LENGTH = 20;\n\tpublic static final int LENGTH = 32;\n\n\tprivate byte[] identifier;\n\n\tpublic ProfileFingerprint()\n\t{\n\n\t}\n\n\tpublic ProfileFingerprint(byte[] identifier)\n\t{\n\t\tObjects.requireNonNull(identifier, \"Null identifier\");\n\t\tif (identifier.length != V4_LENGTH && identifier.length != LENGTH)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Bad identifier length, expected \" + V4_LENGTH + \" or \" + LENGTH + \", got \" + identifier.length);\n\t\t}\n\t\tthis.identifier = identifier;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic byte[] getBytes()\n\t{\n\t\treturn identifier;\n\t}\n\n\t// This is used for serialization (for example passing a ProfileFingerprint in a STOMP message)\n\tpublic void setBytes(byte[] identifier)\n\t{\n\t\tthis.identifier = identifier;\n\t}\n\n\t@Override\n\tpublic int getLength()\n\t{\n\t\tif (identifier == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"getLength() called on ProfileFingerprint, which doesn't support null identifiers\");\n\t\t}\n\t\treturn identifier.length;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (ProfileFingerprint) o;\n\t\treturn Arrays.equals(identifier, that.identifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Arrays.hashCode(identifier);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\tvar s = Id.toString(identifier);\n\t\tvar out = new StringBuilder();\n\t\tvar length = identifier.length * 2;\n\n\t\tfor (var i = 0; i < length; i += 4)\n\t\t{\n\t\t\tif (i > 0)\n\t\t\t{\n\t\t\t\tif (i == 20)\n\t\t\t\t{\n\t\t\t\t\tout.append(\"  \");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tout.append(\" \");\n\t\t\t\t}\n\t\t\t}\n\t\t\tout.append(s, i, i + 4);\n\t\t}\n\t\treturn out.toString();\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/id/Sha1Sum.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport jakarta.persistence.Embeddable;\n\nimport java.util.Arrays;\nimport java.util.Objects;\n\n@Embeddable\npublic class Sha1Sum implements Identifier, Cloneable, Comparable<Sha1Sum>\n{\n\tpublic static final int LENGTH = 20;\n\n\tpublic static final byte[] NULL_IDENTIFIER = Identifier.createNullIdentifier(LENGTH); // NOSONAR\n\n\tprivate byte[] identifier;\n\n\tpublic Sha1Sum()\n\t{\n\t\t// Needed for JPA\n\t}\n\n\tpublic Sha1Sum(byte[] sum)\n\t{\n\t\tObjects.requireNonNull(sum, \"Null sha1 sum\");\n\t\tif (sum.length != LENGTH)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Bad sha1 sum length, expected \" + LENGTH + \", got \" + sum.length);\n\t\t}\n\t\tidentifier = sum;\n\t}\n\n\t/**\n\t * Creates a {@link Sha1Sum} from a string.\n\t *\n\t * @param from a string representing the Sha1Sum in hexadecimal form (lowercase, no prefix)\n\t * @return the Sha1Sum or an empty Sha1Sum if the string was invalid\n\t */\n\tpublic static Sha1Sum fromString(String from)\n\t{\n\t\treturn new Sha1Sum(Identifier.parseString(from, LENGTH));\n\t}\n\n\t@Override\n\tpublic byte[] getBytes()\n\t{\n\t\treturn identifier;\n\t}\n\n\t// This is used for serialization (for example passing a GxsId in a STOMP message)\n\tpublic void setBytes(byte[] identifier)\n\t{\n\t\tthis.identifier = identifier;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic int getLength()\n\t{\n\t\treturn LENGTH;\n\t}\n\n\t@JsonIgnore\n\t@Override\n\tpublic byte[] getNullIdentifier()\n\t{\n\t\treturn NULL_IDENTIFIER;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar that = (Sha1Sum) o;\n\t\treturn Arrays.equals(identifier, that.identifier);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Arrays.hashCode(identifier);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn Id.toString(identifier);\n\t}\n\n\t@Override\n\tpublic Sha1Sum clone()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar clone = (Sha1Sum) super.clone();\n\t\t\tclone.identifier = identifier.clone();\n\t\t\treturn clone;\n\t\t}\n\t\tcatch (CloneNotSupportedException _)\n\t\t{\n\t\t\tthrow new AssertionError();\n\t\t}\n\t}\n\n\t@Override\n\tpublic int compareTo(Sha1Sum o)\n\t{\n\t\treturn Arrays.compare(identifier, o.identifier);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/identity/Type.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.identity;\n\npublic enum Type\n{\n\t/**\n\t * Anything else then the below options.\n\t */\n\tOTHER,\n\n\t/**\n\t * Own identity-\n\t */\n\tOWN,\n\n\t/**\n\t * Identity owned by a friend.\n\t */\n\tFRIEND,\n\n\t/**\n\t * Banned identity.\n\t */\n\tBANNED\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/location/Availability.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.location;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\n\nimport java.util.ResourceBundle;\n\npublic enum Availability implements I18nEnum\n{\n\tAVAILABLE,\n\tBUSY,\n\tAWAY,\n\tOFFLINE;\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn bundle.getString(getMessageKey(this));\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/MessageHeaders.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message;\n\npublic final class MessageHeaders\n{\n\tpublic static final String MESSAGE_TYPE = \"messageType\";\n\tpublic static final String DESTINATION_ID = \"destinationId\";\n\n\tprivate MessageHeaders()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/MessagePath.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message;\n\npublic final class MessagePath\n{\n\tpublic static final String BROKER_PREFIX = \"/topic\";\n\tpublic static final String DIRECT_PREFIX = \"/queue\";\n\tpublic static final String APP_PREFIX = \"/app\";\n\n\tpublic static final String CHAT_ROOT = \"/chat\";\n\tpublic static final String CHAT_PRIVATE_DESTINATION = \"/private\";\n\tpublic static final String CHAT_ROOM_DESTINATION = \"/room\";\n\tpublic static final String CHAT_BROADCAST_DESTINATION = \"/broadcast\";\n\tpublic static final String CHAT_DISTANT_DESTINATION = \"/distant\";\n\n\tpublic static final String VOIP_ROOT = \"/voip\";\n\tpublic static final String VOIP_PRIVATE_DESTINATION = \"/private\";\n\n\tprivate MessagePath()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String chatPrivateDestination()\n\t{\n\t\treturn BROKER_PREFIX + CHAT_ROOT + CHAT_PRIVATE_DESTINATION;\n\t}\n\n\tpublic static String chatRoomDestination()\n\t{\n\t\treturn BROKER_PREFIX + CHAT_ROOT + CHAT_ROOM_DESTINATION;\n\t}\n\n\tpublic static String chatBroadcastDestination()\n\t{\n\t\treturn BROKER_PREFIX + CHAT_ROOT + CHAT_BROADCAST_DESTINATION;\n\t}\n\n\tpublic static String chatDistantDestination()\n\t{\n\t\treturn BROKER_PREFIX + CHAT_ROOT + CHAT_DISTANT_DESTINATION;\n\t}\n\n\tpublic static String voipPrivateDestination()\n\t{\n\t\treturn BROKER_PREFIX + VOIP_ROOT + VOIP_PRIVATE_DESTINATION;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/MessageType.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message;\n\npublic enum MessageType\n{\n\tNONE, // use this when not needing any\n\tCHAT_PRIVATE_MESSAGE,\n\tCHAT_ROOM_MESSAGE,\n\tCHAT_ROOM_LIST,\n\tCHAT_BROADCAST_MESSAGE,\n\tCHAT_TYPING_NOTIFICATION,\n\tCHAT_ROOM_JOIN,\n\tCHAT_ROOM_LEAVE,\n\tCHAT_ROOM_TYPING_NOTIFICATION,\n\tCHAT_ROOM_USER_JOIN,\n\tCHAT_ROOM_USER_LEAVE,\n\tCHAT_ROOM_USER_KEEP_ALIVE,\n\tCHAT_ROOM_USER_TIMEOUT,\n\tCHAT_ROOM_INVITE,\n\tCHAT_AVATAR,\n\tCHAT_AVAILABILITY\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/MessagingConfiguration.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message;\n\npublic final class MessagingConfiguration\n{\n\t/**\n\t * The maximum size of a message (1 MB).\n\t */\n\tpublic static final int MAXIMUM_MESSAGE_SIZE = 1024 * 1024;\n\n\tprivate MessagingConfiguration()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatAvatar.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\npublic class ChatAvatar\n{\n\tprivate byte[] image;\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON\n\tpublic ChatAvatar()\n\t{\n\t}\n\n\tpublic ChatAvatar(byte[] image)\n\t{\n\t\tthis.image = image;\n\t}\n\n\tpublic byte[] getImage()\n\t{\n\t\treturn image;\n\t}\n\n\tpublic void setImage(byte[] image)\n\t{\n\t\tthis.image = image;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatAvatar{\" +\n\t\t\t\t\"image size=\" + image.length +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatBacklog.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport java.time.Instant;\n\npublic record ChatBacklog(Instant created, boolean own, String message)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatConstants.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport java.time.Duration;\n\npublic final class ChatConstants\n{\n\tpublic static final Duration TYPING_NOTIFICATION_DELAY = Duration.ofSeconds(5);\n\n\tprivate ChatConstants()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatMessage.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\n\n/**\n * Used to send messages from a chat client to a web socket only.\n * If a chat message has no content, it's a notification.\n */\npublic class ChatMessage\n{\n\tprivate String content;\n\tprivate boolean own;\n\n\tpublic ChatMessage()\n\t{\n\t\t// Needed for JSON\n\t}\n\n\tpublic ChatMessage(String message)\n\t{\n\t\tcontent = message;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\tpublic boolean isOwn()\n\t{\n\t\treturn own;\n\t}\n\n\tpublic void setOwn(boolean own)\n\t{\n\t\tthis.own = own;\n\t}\n\n\t@JsonIgnore\n\tpublic boolean isEmpty()\n\t{\n\t\treturn content == null;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatMessage{\" +\n\t\t\t\t\"content='\" + content + \"'\" +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomBacklog.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic record ChatRoomBacklog(Instant created, GxsId gxsId, String nickname, String message)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomContext.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\npublic record ChatRoomContext(ChatRoomLists chatRoomLists, ChatRoomUser ownUser)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomInfo.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport java.util.Objects;\n\npublic class ChatRoomInfo\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate RoomType roomType;\n\tprivate String topic;\n\tprivate int count;\n\tprivate boolean isSigned;\n\tprivate boolean newMessages;\n\n\tpublic ChatRoomInfo()\n\t{\n\n\t}\n\n\tpublic ChatRoomInfo(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic ChatRoomInfo(long id, String name, RoomType roomType, String topic, int count, boolean isSigned)\n\t{\n\t\tthis.id = id;\n\t\tthis.name = name;\n\t\tthis.roomType = roomType;\n\t\tthis.topic = topic;\n\t\tthis.count = count;\n\t\tthis.isSigned = isSigned;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic RoomType getRoomType()\n\t{\n\t\treturn roomType;\n\t}\n\n\tpublic void setRoomType(RoomType roomType)\n\t{\n\t\tthis.roomType = roomType;\n\t}\n\n\tpublic String getTopic()\n\t{\n\t\treturn topic;\n\t}\n\n\tpublic void setTopic(String topic)\n\t{\n\t\tthis.topic = topic;\n\t}\n\n\tpublic int getCount()\n\t{\n\t\treturn count;\n\t}\n\n\tpublic void setCount(int count)\n\t{\n\t\tthis.count = count;\n\t}\n\n\tpublic boolean isSigned()\n\t{\n\t\treturn isSigned;\n\t}\n\n\tpublic void setSigned(boolean signed)\n\t{\n\t\tisSigned = signed;\n\t}\n\n\tpublic boolean hasNewMessages()\n\t{\n\t\treturn newMessages;\n\t}\n\n\tpublic void setNewMessages(boolean newMessages)\n\t{\n\t\tthis.newMessages = newMessages;\n\t}\n\n\tpublic boolean isReal()\n\t{\n\t\treturn id != 0L;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar roomInfo = (ChatRoomInfo) o;\n\t\treturn id == roomInfo.id;\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hash(id);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn name;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomInviteEvent.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\npublic class ChatRoomInviteEvent\n{\n\tprivate String locationIdentifier;\n\tprivate String roomName;\n\tprivate String roomTopic;\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON\n\tpublic ChatRoomInviteEvent()\n\t{\n\t}\n\n\tpublic ChatRoomInviteEvent(String locationIdentifier, String roomName, String roomTopic)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t\tthis.roomName = roomName;\n\t\tthis.roomTopic = roomTopic;\n\t}\n\n\tpublic String getRoomName()\n\t{\n\t\treturn roomName;\n\t}\n\n\tpublic void setRoomName(String roomName)\n\t{\n\t\tthis.roomName = roomName;\n\t}\n\n\tpublic String getRoomTopic()\n\t{\n\t\treturn roomTopic;\n\t}\n\n\tpublic void setRoomTopic(String roomTopic)\n\t{\n\t\tthis.roomTopic = roomTopic;\n\t}\n\n\tpublic String getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic void setLocationIdentifier(String locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomInviteEvent{\" +\n\t\t\t\t\"locationIdentifier='\" + locationIdentifier + '\\'' +\n\t\t\t\t\", roomName='\" + roomName + '\\'' +\n\t\t\t\t\", roomTopic='\" + roomTopic + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomLists.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class ChatRoomLists\n{\n\tprivate List<ChatRoomInfo> subscribedRooms = new ArrayList<>();\n\tprivate List<ChatRoomInfo> availableRooms = new ArrayList<>();\n\n\tpublic void addSubscribed(ChatRoomInfo chatRoomInfo)\n\t{\n\t\tsubscribedRooms.add(chatRoomInfo);\n\t}\n\n\tpublic void addAvailable(ChatRoomInfo chatRoomInfo)\n\t{\n\t\tavailableRooms.add(chatRoomInfo);\n\t}\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON serialization\n\tpublic void setSubscribedRooms(List<ChatRoomInfo> subscribedRooms)\n\t{\n\t\tthis.subscribedRooms = subscribedRooms;\n\t}\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON serialization\n\tpublic void setAvailableRooms(List<ChatRoomInfo> availableRooms)\n\t{\n\t\tthis.availableRooms = availableRooms;\n\t}\n\n\tpublic List<ChatRoomInfo> getSubscribedRooms()\n\t{\n\t\treturn subscribedRooms;\n\t}\n\n\tpublic List<ChatRoomInfo> getAvailableRooms()\n\t{\n\t\treturn availableRooms;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomMessage.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport io.xeres.common.id.GxsId;\n\npublic class ChatRoomMessage\n{\n\tprivate long roomId;\n\tprivate String senderNickname;\n\tprivate GxsId gxsId;\n\tprivate String content;\n\n\tpublic ChatRoomMessage()\n\t{\n\t\t// Needed for JSON\n\t}\n\n\tpublic ChatRoomMessage(String senderNickname, GxsId gxsId, String content)\n\t{\n\t\tthis.senderNickname = senderNickname;\n\t\tthis.gxsId = gxsId;\n\t\tthis.content = content;\n\t}\n\n\tpublic long getRoomId()\n\t{\n\t\treturn roomId;\n\t}\n\n\tpublic void setRoomId(long roomId)\n\t{\n\t\tthis.roomId = roomId;\n\t}\n\n\tpublic String getSenderNickname()\n\t{\n\t\treturn senderNickname;\n\t}\n\n\tpublic void setSenderNickname(String senderNickname)\n\t{\n\t\tthis.senderNickname = senderNickname;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\t@JsonIgnore\n\tpublic boolean isOwn()\n\t{\n\t\treturn senderNickname == null;\n\t}\n\n\t@JsonIgnore\n\tpublic boolean isEmpty()\n\t{\n\t\treturn content == null;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatRoomMessage{\" +\n\t\t\t\t\"roomId=\" + roomId +\n\t\t\t\t\", senderNickname='\" + senderNickname + '\\'' +\n\t\t\t\t\", gxsId'\" + gxsId + '\\'' +\n\t\t\t\t\", content='\" + content + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomTimeoutEvent.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport io.xeres.common.id.GxsId;\n\npublic class ChatRoomTimeoutEvent\n{\n\tprivate GxsId gxsId;\n\tprivate boolean split;\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON\n\tpublic ChatRoomTimeoutEvent()\n\t{\n\t}\n\n\tpublic ChatRoomTimeoutEvent(GxsId gxsId, boolean split)\n\t{\n\t\tthis.gxsId = gxsId;\n\t\tthis.split = split;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic boolean isSplit()\n\t{\n\t\treturn split;\n\t}\n\n\tpublic void setSplit(boolean split)\n\t{\n\t\tthis.split = split;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomUser.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport io.xeres.common.id.GxsId;\n\npublic record ChatRoomUser(String nickname, GxsId gxsId, long identityId)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/ChatRoomUserEvent.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport io.xeres.common.id.GxsId;\n\npublic class ChatRoomUserEvent\n{\n\tprivate GxsId gxsId;\n\tprivate String nickname;\n\tprivate long identityId;\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON\n\tpublic ChatRoomUserEvent()\n\t{\n\t}\n\n\tpublic ChatRoomUserEvent(GxsId gxsId, String nickname, long identityId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t\tthis.nickname = nickname;\n\t\tthis.identityId = identityId;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic String getNickname()\n\t{\n\t\treturn nickname != null ? nickname : \"\"; // Workaround against users having a null nickname\n\t}\n\n\tpublic void setNickname(String nickname)\n\t{\n\t\tthis.nickname = nickname;\n\t}\n\n\tpublic long getIdentityId()\n\t{\n\t\treturn identityId;\n\t}\n\n\tpublic void setIdentityId(long identityId)\n\t{\n\t\tthis.identityId = identityId;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/chat/RoomType.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.chat;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\n\nimport java.util.ResourceBundle;\n\npublic enum RoomType implements I18nEnum\n{\n\tPRIVATE,\n\tPUBLIC;\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn bundle.getString(getMessageKey(this));\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/voip/VoipAction.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.voip;\n\npublic enum VoipAction\n{\n\tRING,\n\tACKNOWLEDGE,\n\tCLOSE\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/message/voip/VoipMessage.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.message.voip;\n\npublic class VoipMessage\n{\n\tprivate VoipAction action;\n\n\t@SuppressWarnings(\"unused\") // Needed for JSON\n\tpublic VoipMessage()\n\t{\n\t}\n\n\tpublic VoipMessage(VoipAction action)\n\t{\n\t\tthis.action = action;\n\t}\n\n\tpublic VoipAction getAction()\n\t{\n\t\treturn action;\n\t}\n\n\tpublic void setAction(VoipAction action)\n\t{\n\t\tthis.action = action;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"VoipMessage{\" +\n\t\t\t\t\"action=\" + action +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/mui/MUI.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.mui;\n\nimport io.xeres.common.AppName;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.swing.*;\nimport java.awt.*;\nimport java.awt.event.*;\nimport java.io.IOException;\nimport java.util.Objects;\n\n/**\n * MUI: the Minimal User Interface.\n * <p>\n * Just an interface to show some error to the user when failing to start in non-headless mode. It also contains a minimal shell.\n * <p>\n * Without Xeres, MUI wouldn't exist :)\n */\npublic final class MUI\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(MUI.class);\n\n\tprivate static final String PROMPT = \"1.SYS:> \";\n\tprivate static JFrame shellFrame;\n\tprivate static JTextArea textArea;\n\tprivate static Shell shell;\n\tprivate static String currentLine = \"\";\n\n\tprivate MUI()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void setShell(Shell shell)\n\t{\n\t\tMUI.shell = shell;\n\t}\n\n\t/**\n\t * Shows an informational message.\n\t * <p>\n\t * Only use this when JavaFX is not available (for example displaying command arguments on Windows).\n\t *\n\t * @param message the message to display to the user\n\t */\n\tpublic static void showInformation(String message)\n\t{\n\t\tJOptionPane.showMessageDialog(null, message, AppName.NAME + \" Output\", JOptionPane.INFORMATION_MESSAGE);\n\t}\n\n\t/**\n\t * Shows an error.\n\t * <p>\n\t * Only use this when JavaFX is not available. Typically, when its initialization goes wrong.\n\t * @param e the Exception\n\t */\n\tpublic static void showError(Exception e)\n\t{\n\t\tThrowable exception = e;\n\n\t\twhile (exception.getCause() != null)\n\t\t{\n\t\t\texception = exception.getCause();\n\t\t}\n\t\tshowError(exception.getMessage());\n\t}\n\n\tprivate static void showError(String message)\n\t{\n\t\tvar scrollPane = new JScrollPane();\n\t\tscrollPane.setPreferredSize(new Dimension(640, 240));\n\t\tvar textArea = new JTextArea(message);\n\t\ttextArea.setEditable(false);\n\t\ttextArea.setLineWrap(true);\n\t\ttextArea.setWrapStyleWord(true);\n\t\ttextArea.setMargin(new Insets(8, 8, 8, 8));\n\t\tscrollPane.getViewport().setView(textArea);\n\n\t\tJOptionPane.showMessageDialog(null, scrollPane, AppName.NAME + \" Runtime Problem\", JOptionPane.ERROR_MESSAGE);\n\t}\n\n\tpublic static void openShell()\n\t{\n\t\tif (shellFrame == null)\n\t\t{\n\t\t\tcreateShellFrame(shell);\n\t\t}\n\t\tif (!shellFrame.isVisible())\n\t\t{\n\t\t\tappendToTextArea(\"\"\"\n\t\t\t\t\tNew Shell process 1\n\t\t\t\t\tType 'help' for more information.\n\t\t\t\t\t\"\"\");\n\t\t\ttextArea.setCaretPosition(textArea.getDocument().getLength());\n\t\t\tshellFrame.setVisible(true);\n\t\t}\n\t\tshellFrame.toFront();\n\t\ttextArea.requestFocus();\n\t}\n\n\tprivate static void createShellFrame(Shell shell)\n\t{\n\t\tObjects.requireNonNull(shell, \"a shell is required\");\n\n\t\ttextArea = new JTextArea()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void paste()\n\t\t\t{\n\t\t\t\tsuper.paste();\n\t\t\t\tupdateLastLine();\n\t\t\t}\n\t\t};\n\t\ttextArea.setEditable(true);\n\t\ttextArea.setLineWrap(true);\n\t\ttextArea.setWrapStyleWord(true);\n\t\ttextArea.setMargin(new Insets(8, 8, 8, 8));\n\t\ttextArea.setBackground(Color.GRAY);\n\t\ttextArea.setForeground(Color.BLACK);\n\n\t\tvar scrollPane = new JScrollPane(textArea);\n\t\tscrollPane.setPreferredSize(new Dimension(640, 320));\n\t\tscrollPane.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));\n\t\tscrollPane.getVerticalScrollBar().setUI(new MUIScrollBar());\n\t\tscrollPane.getHorizontalScrollBar().setUI(new MUIScrollBar());\n\n\t\ttextArea.addMouseListener(new MouseAdapter()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void mouseClicked(MouseEvent e)\n\t\t\t{\n\t\t\t\ttextArea.setCaretPosition(textArea.getDocument().getLength());\n\t\t\t}\n\t\t});\n\n\t\ttry\n\t\t{\n\t\t\tvar font = Font.createFont(Font.TRUETYPE_FONT, Objects.requireNonNull(MUI.class.getResourceAsStream(\"/topaz.ttf\")));\n\t\t\tvar derivedFont = font.deriveFont(Font.PLAIN, 14f);\n\t\t\ttextArea.setFont(derivedFont);\n\t\t}\n\t\tcatch (FontFormatException | IOException e)\n\t\t{\n\t\t\tlog.error(\"Failed to set custom font, guru meditation: {}\", e.getMessage());\n\t\t}\n\n\t\ttextArea.addKeyListener(new KeyListener()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void keyTyped(KeyEvent e)\n\t\t\t{\n\t\t\t\tif (e.getKeyChar() == '\\n')\n\t\t\t\t{\n\t\t\t\t\te.consume();\n\t\t\t\t}\n\t\t\t\telse if (e.getKeyChar() == '\\b')\n\t\t\t\t{\n\t\t\t\t\tif (!currentLine.isEmpty())\n\t\t\t\t\t{\n\t\t\t\t\t\tcurrentLine = currentLine.substring(0, currentLine.length() - 1);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse if (StringUtils.isAsciiPrintable(String.valueOf(e.getKeyChar()))) // Strip spurious ctrl-v char sequence\n\t\t\t\t{\n\t\t\t\t\tcurrentLine += e.getKeyChar();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic void keyPressed(KeyEvent e)\n\t\t\t{\n\t\t\t\tif (e.getKeyCode() == KeyEvent.VK_BACK_SPACE)\n\t\t\t\t{\n\t\t\t\t\tif (currentLine.isEmpty())\n\t\t\t\t\t{\n\t\t\t\t\t\te.consume();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse if (e.getKeyCode() == KeyEvent.VK_ENTER)\n\t\t\t\t{\n\t\t\t\t\te.consume();\n\t\t\t\t\tvar result = shell.sendCommand(currentLine);\n\t\t\t\t\tswitch (result.getAction())\n\t\t\t\t\t{\n\t\t\t\t\t\tcase UNKNOWN_COMMAND -> appendToTextArea(currentLine + \": Unknown command\");\n\t\t\t\t\t\tcase CLS ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttextArea.setText(\"\");\n\t\t\t\t\t\t\tappendToTextArea(\"\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase EXIT -> closeShell();\n\t\t\t\t\t\tcase NO_OP -> appendToTextArea(\"\");\n\t\t\t\t\t\tcase SUCCESS -> appendToTextArea(result.getOutput());\n\t\t\t\t\t\tcase ERROR -> appendToTextArea(\"Error: \" + result.getOutput());\n\t\t\t\t\t}\n\t\t\t\t\tcurrentLine = \"\";\n\t\t\t\t}\n\t\t\t\telse if (e.getKeyCode() == KeyEvent.VK_UP)\n\t\t\t\t{\n\t\t\t\t\tvar previous = shell.getPreviousCommand();\n\t\t\t\t\tupdateLineHistory(previous);\n\t\t\t\t\te.consume();\n\t\t\t\t}\n\t\t\t\telse if (e.getKeyCode() == KeyEvent.VK_DOWN)\n\t\t\t\t{\n\t\t\t\t\tvar next = shell.getNextCommand();\n\t\t\t\t\tupdateLineHistory(next);\n\t\t\t\t\te.consume();\n\t\t\t\t}\n\t\t\t\telse if ((e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) == InputEvent.CTRL_DOWN_MASK)\n\t\t\t\t{\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (textArea != null) // We might have typed 'exit'\n\t\t\t\t{\n\t\t\t\t\ttextArea.setCaretPosition(textArea.getDocument().getLength());\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic void keyReleased(KeyEvent e)\n\t\t\t{\n\t\t\t\ttextArea.setCaretPosition(textArea.getDocument().getLength());\n\t\t\t}\n\t\t});\n\n\t\tshellFrame = new JFrame(AppName.NAME + \" Shell\");\n\t\tshellFrame.setIconImage(new ImageIcon(Objects.requireNonNull(MUI.class.getResource(\"/image/icon.png\"))).getImage());\n\t\tshellFrame.getContentPane().setLayout(new BoxLayout(shellFrame.getContentPane(), BoxLayout.Y_AXIS));\n\t\tshellFrame.add(scrollPane);\n\t\tshellFrame.pack();\n\t\tshellFrame.setLocationRelativeTo(null);\n\t\tshellFrame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);\n\t\tshellFrame.addWindowListener(new WindowAdapter()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void windowClosing(WindowEvent e)\n\t\t\t{\n\t\t\t\tcloseShell();\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Updates the last line. This is slow, only use it for paste operations or so.\n\t */\n\tprivate static void updateLastLine()\n\t{\n\t\tString fullText = textArea.getText();\n\t\tif (fullText.isEmpty())\n\t\t{\n\t\t\tcurrentLine = \"\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Split by line breaks and get the last non-empty line\n\t\t\tString[] lines = fullText.split(\"\\\\r?\\\\n\");\n\t\t\tcurrentLine = lines[lines.length - 1]\n\t\t\t\t\t.replace(PROMPT, \"\");\n\t\t}\n\t}\n\n\tprivate static void updateLineHistory(String line)\n\t{\n\t\tif (line == null)\n\t\t{\n\t\t\tline = \"\";\n\t\t}\n\n\t\tvar pos = textArea.getDocument().getLength();\n\t\ttextArea.replaceRange(null, pos - currentLine.length(), pos);\n\t\ttextArea.append(line);\n\t\tcurrentLine = line;\n\t}\n\n\tprivate static void appendToTextArea(String text)\n\t{\n\t\tif (!textArea.getText().isEmpty())\n\t\t{\n\t\t\ttextArea.append(\"\\n\");\n\t\t}\n\t\tif (StringUtils.isNotEmpty(text))\n\t\t{\n\t\t\ttextArea.append(text + \"\\n\");\n\t\t}\n\t\ttextArea.append(PROMPT);\n\t\ttextArea.setCaretPosition(textArea.getDocument().getLength());\n\t}\n\n\tpublic static void closeShell()\n\t{\n\t\tif (shellFrame != null)\n\t\t{\n\t\t\tshellFrame.setVisible(false);\n\t\t\tshellFrame.dispose();\n\t\t\ttextArea = null;\n\t\t\tshellFrame = null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/mui/MUIScrollBar.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.mui;\n\nimport javax.swing.*;\nimport javax.swing.plaf.basic.BasicScrollBarUI;\nimport java.awt.*;\n\npublic class MUIScrollBar extends BasicScrollBarUI\n{\n\t@Override\n\tprotected void configureScrollBarColors()\n\t{\n\t\tsuper.configureScrollBarColors();\n\t\tthumbColor = new Color(102, 136, 187);\n\t\ttrackColor = Color.DARK_GRAY;\n\t}\n\n\t@Override\n\tprotected JButton createDecreaseButton(int orientation)\n\t{\n\t\treturn createZeroButton();\n\t}\n\n\t@Override\n\tprotected JButton createIncreaseButton(int orientation)\n\t{\n\t\treturn createZeroButton();\n\t}\n\n\tprivate JButton createZeroButton()\n\t{\n\t\tvar button = new JButton();\n\t\tbutton.setPreferredSize(new Dimension(0, 0));\n\t\tbutton.setMinimumSize(new Dimension(0, 0));\n\t\tbutton.setMaximumSize(new Dimension(0, 0));\n\t\treturn button;\n\t}\n\n\t@Override\n\tprotected void paintThumb(Graphics g, JComponent c, Rectangle thumbBounds)\n\t{\n\t\tGraphics2D g2 = (Graphics2D) g.create();\n\t\tg2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);\n\n\t\tg2.setColor(thumbColor);\n\t\tg2.fill3DRect(thumbBounds.x, thumbBounds.y, thumbBounds.width, thumbBounds.height, true);\n\n\t\tg2.dispose();\n\t}\n\n\t@Override\n\tprotected void paintTrack(Graphics g, JComponent c, Rectangle trackBounds)\n\t{\n\t\tGraphics2D g2 = (Graphics2D) g.create();\n\t\tg2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);\n\n\t\tg2.setColor(trackColor);\n\t\tg2.fillRect(trackBounds.x, trackBounds.y, trackBounds.width, trackBounds.height);\n\n\t\tg2.dispose();\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/mui/Shell.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.mui;\n\npublic interface Shell\n{\n\tShellResult sendCommand(String input);\n\n\tString getPreviousCommand();\n\n\tString getNextCommand();\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/mui/ShellAction.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.mui;\n\npublic enum ShellAction\n{\n\tUNKNOWN_COMMAND,\n\tEXIT,\n\tCLS,\n\tSUCCESS,\n\tERROR,\n\tNO_OP\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/mui/ShellResult.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.mui;\n\npublic class ShellResult\n{\n\tfinal ShellAction action;\n\tString output;\n\n\tpublic ShellResult(ShellAction action, String output)\n\t{\n\t\tthis.action = action;\n\t\tthis.output = output;\n\t}\n\n\tpublic ShellResult(ShellAction action)\n\t{\n\t\tthis.action = action;\n\t}\n\n\tpublic ShellAction getAction()\n\t{\n\t\treturn action;\n\t}\n\n\tpublic String getOutput()\n\t{\n\t\treturn output;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/pgp/Trust.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.pgp;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\n\nimport java.util.ResourceBundle;\n\n/**\n * This is the trust level for a PGP-like \"web of trust\" feature. Note that\n * 'undefined' is not here because it's confusing.\n * <p>\n * Note: this is stored in the database in ordinal. Do not modify the order.\n */\npublic enum Trust implements I18nEnum\n{\n\t/**\n\t * No opinion about the trustworthiness of the owner.\n\t */\n\tUNKNOWN,\n\n\t/**\n\t * No trust about the owner. For example, he's known to sign stuff without\n\t * checking or without the other owner's consent.\n\t */\n\tNEVER,\n\n\t/**\n\t * Trust that the owner doesn't perform certifications blindly but not\n\t * very accurately either. Trust will only become valid after multiple certifications (usually 3).\n\t * A good default choice.\n\t */\n\tMARGINAL,\n\n\t/**\n\t * Trust that the owner performs certification very accurately. Trust\n\t * will become valid after a single one so use with care.\n\t */\n\tFULL,\n\n\t/**\n\t * Our own key.\n\t */\n\tULTIMATE;\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn bundle.getString(getMessageKey(this));\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/properties/StartupProperties.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.properties;\n\nimport io.xeres.common.protocol.ip.IP;\nimport org.apache.commons.lang3.StringUtils;\n\npublic final class StartupProperties\n{\n\tpublic enum Property\n\t{\n\t\tSERVER_ONLY(\"xrs.network.server-only\", Boolean.class, Origin.PROPERTY),\n\t\tCONTROL_PORT(\"server.port\", Integer.class, Origin.PROPERTY),\n\t\tCONTROL_ADDRESS(\"server.address\", String.class, Origin.PROPERTY),\n\t\tCONTROL_PASSWORD(\"xrs.server.password\", Boolean.class, Origin.PROPERTY),\n\t\tSERVER_ADDRESS(\"xrs.network.server-address\", String.class, Origin.PROPERTY),\n\t\tSERVER_PORT(\"xrs.network.server-port\", Integer.class, Origin.PROPERTY),\n\t\tDATA_DIR(\"xrs.data.dir-path\", String.class, Origin.PROPERTY),\n\t\tUI(\"xrs.ui.enabled\", Boolean.class, Origin.PROPERTY),\n\t\tUI_ADDRESS(\"xrs.ui.address\", String.class, Origin.PROPERTY),\n\t\tUI_PORT(\"xrs.ui.port\", Integer.class, Origin.PROPERTY),\n\t\tICONIFIED(\"xrs.ui.iconified\", Boolean.class, Origin.PROPERTY),\n\t\tFAST_SHUTDOWN(\"xrs.network.fast-shutdown\", Boolean.class, Origin.PROPERTY),\n\t\tREMOTE_PASSWORD(\"xrs.ui.remote-password\", String.class, Origin.PROPERTY),\n\t\tHTTPS(\"server.ssl.enabled\", Boolean.class, Origin.PROPERTY),\n\t\tLOGFILE(\"logging.file.name\", String.class, Origin.PROPERTY);\n\n\t\tProperty(String propertyName, Class<?> javaClass, Origin origin)\n\t\t{\n\t\t\tthis.propertyName = propertyName;\n\t\t\tthis.javaClass = javaClass;\n\t\t\tthis.origin = origin;\n\t\t}\n\n\t\tprivate final String propertyName;\n\t\tprivate final Class<?> javaClass;\n\t\tprivate Origin origin;\n\n\t\tpublic String getKey()\n\t\t{\n\t\t\treturn propertyName;\n\t\t}\n\n\t\tpublic Class<?> getJavaClass()\n\t\t{\n\t\t\treturn javaClass;\n\t\t}\n\n\t\tpublic Origin getOrigin()\n\t\t{\n\t\t\treturn origin;\n\t\t}\n\n\t\tprivate void setOrigin(Origin origin)\n\t\t{\n\t\t\tthis.origin = origin;\n\t\t}\n\n\t\t/**\n\t\t * Checks if an argument was set by command line or environment variable.\n\t\t *\n\t\t * @return true if set by env var or command line\n\t\t */\n\t\tpublic boolean isUnset()\n\t\t{\n\t\t\treturn origin == Origin.PROPERTY;\n\t\t}\n\t}\n\n\tpublic enum Origin\n\t{\n\t\tPROPERTY,\n\t\tENVIRONMENT_VARIABLE,\n\t\tARGUMENT\n\t}\n\n\tprivate StartupProperties()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String getString(Property property, String defaultValue)\n\t{\n\t\treturn System.getProperty(property.getKey(), defaultValue);\n\t}\n\n\tpublic static String getString(Property property)\n\t{\n\t\treturn System.getProperty(property.getKey());\n\t}\n\n\tpublic static void setString(Property property, String value, Origin origin)\n\t{\n\t\tif (!property.getJavaClass().equals(String.class))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Property class for \" + property.getKey() + \" must be a String but it's a \" + property.getJavaClass());\n\t\t}\n\n\t\tif (StringUtils.isBlank(value))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Property \" + property.name() + \" (\" + property.getKey() + \") does not contain a value\");\n\t\t}\n\n\t\tproperty.setOrigin(origin);\n\t\tSystem.setProperty(property.getKey(), value);\n\t}\n\n\t@SuppressWarnings(\"java:S2447\")\n\tpublic static Boolean getBoolean(Property property)\n\t{\n\t\tvar value = System.getProperty(property.getKey());\n\t\tif (value == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn Boolean.parseBoolean(value);\n\t}\n\n\tpublic static boolean getBoolean(Property property, boolean defaultValue)\n\t{\n\t\tvar value = System.getProperty(property.getKey());\n\t\tif (value == null)\n\t\t{\n\t\t\treturn defaultValue;\n\t\t}\n\t\treturn Boolean.parseBoolean(value);\n\t}\n\n\tpublic static void setBoolean(Property property, String value, Origin origin)\n\t{\n\t\tif (!property.getJavaClass().equals(Boolean.class))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Property class for \" + property.getKey() + \" must be a Boolean but it's a \" + property.getJavaClass());\n\t\t}\n\n\t\tvar val = value.equals(\"1\") || value.equalsIgnoreCase(\"yes\") || Boolean.parseBoolean(value);\n\t\tif (!val && !(value.equals(\"0\") || value.equalsIgnoreCase(\"no\") || value.equalsIgnoreCase(\"false\")))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Property \" + property.name() + \" (\" + property.getKey() + \") does not contain a boolean value (\" + value + \")\");\n\t\t}\n\t\tproperty.setOrigin(origin);\n\t\tSystem.setProperty(property.getKey(), String.valueOf(val));\n\t}\n\n\tpublic static Integer getInteger(Property property)\n\t{\n\t\tvar value = System.getProperty(property.getKey());\n\t\tif (value == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn Integer.parseInt(value);\n\t}\n\n\tpublic static void setPort(Property property, String value, Origin origin)\n\t{\n\t\tif (!property.getJavaClass().equals(Integer.class))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Property class for \" + property.getKey() + \" must be an Integer but it's a \" + property.getJavaClass());\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tvar val = Integer.parseUnsignedInt(value);\n\t\t\tif (IP.isInvalidPort(val))\n\t\t\t{\n\t\t\t\tthrow new NumberFormatException();\n\t\t\t}\n\t\t\tproperty.setOrigin(origin);\n\t\t\tSystem.setProperty(property.getKey(), String.valueOf(val));\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Property \" + property.name() + \" (\" + property.getKey() + \") does not contain a port bigger than 0 and smaller than 65536 (\" + value + \")\");\n\t\t}\n\t}\n\n\tpublic static void setUiRemoteConnect(String ipAndPort, Origin origin)\n\t{\n\t\tvar tokens = ipAndPort.split(\":\");\n\n\t\tif (StringUtils.isBlank(tokens[0]))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing hostname\");\n\t\t}\n\t\tif (!IP.isBindableIp(tokens[0]))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"IP \" + tokens[0] + \" cannot be bound to\");\n\t\t}\n\t\tsetString(Property.UI_ADDRESS, tokens[0], origin);\n\n\t\tif (tokens.length == 2 && StringUtils.isNotBlank(tokens[1]))\n\t\t{\n\t\t\tif (IP.isInvalidPort(Integer.parseUnsignedInt(tokens[1])))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Invalid port \" + tokens[1]);\n\t\t\t}\n\t\t\tsetPort(Property.UI_PORT, tokens[1], origin);\n\t\t}\n\t\tSystem.setProperty(\"spring.main.web-application-type\", \"none\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/HostPort.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol;\n\nimport static org.apache.commons.lang3.StringUtils.isBlank;\n\npublic record HostPort(String host, int port)\n{\n\tpublic static HostPort parse(String hostPort)\n\t{\n\t\tvar tokens = hostPort.split(\":\");\n\t\tint port;\n\t\tif (tokens.length != 2)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Input is not in \\\"host:port\\\" format: \" + hostPort);\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tport = Integer.parseInt(tokens[1]);\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Port is not a number: \" + tokens[1]);\n\t\t}\n\n\t\tif (port < 0 || port > 65535)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Port is out of range: \" + port);\n\t\t}\n\n\t\tif (isBlank(tokens[0]))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Host is missing\");\n\t\t}\n\n\t\treturn new HostPort(tokens[0], port);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/NetMode.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol;\n\nimport java.util.Locale;\n\n/**\n * The NetMode<br>\n * Note: this is stored in the database in ordinal. Do not modify the order.\n */\npublic enum NetMode\n{\n\tUNKNOWN, // Unknown netmode\n\tUDP, // firewalled | UDP mode\n\tUPNP, // automatic (UPNP) | Ext (UPNP)\n\tEXT, // manually forwarded port | External port\n\tHIDDEN, // hidden mode | Hidden\n\tUNREACHABLE; // UDP mode (unreachable)\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn super.toString().toLowerCase(Locale.ROOT);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/dns/DNS.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.dns;\n\nimport java.io.IOException;\nimport java.net.DatagramPacket;\nimport java.net.DatagramSocket;\nimport java.net.InetAddress;\nimport java.time.Duration;\n\npublic final class DNS\n{\n\tprivate static final Duration TIMEOUT = Duration.ofSeconds(10);\n\tprivate static final int DNS_PORT = 53;\n\n\tprivate DNS()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * @param host      the host to resolve\n\t * @param dnsServer the dns server to resolve against\n\t * @return the IP address of the host\n\t * @throws IOException failure to resolve\n\t * @see <a href=\"https://stackoverflow.com/a/39375234/1811760\">Stack Overflow</a>\n\t */\n\tpublic static InetAddress resolve(String host, String dnsServer) throws IOException\n\t{\n\t\tvar serverAddress = InetAddress.getByName(dnsServer);\n\n\t\tvar request = new DnsRequest(host);\n\n\t\tvar dnsFrame = request.toByteArray();\n\n\t\ttry (var socket = new DatagramSocket())\n\t\t{\n\t\t\tsocket.setSoTimeout((int) TIMEOUT.toMillis());\n\n\t\t\tvar dnsReqPacket = new DatagramPacket(dnsFrame, dnsFrame.length, serverAddress, DNS_PORT);\n\t\t\tsocket.send(dnsReqPacket);\n\n\t\t\tvar buf = new byte[1024];\n\t\t\tvar packet = new DatagramPacket(buf, buf.length);\n\t\t\tsocket.receive(packet);\n\n\t\t\tvar response = new DnsResponse(buf, request.getId());\n\t\t\treturn response.getAddress();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/dns/DnsRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.dns;\n\nimport io.xeres.common.util.SecureRandomUtils;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.DataOutputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\n\nclass DnsRequest\n{\n\tprivate final ByteArrayOutputStream array;\n\tprivate final short id;\n\n\tDnsRequest(String hostname) throws IOException\n\t{\n\t\tid = SecureRandomUtils.nextShort();\n\n\t\tarray = new ByteArrayOutputStream();\n\t\tvar out = new DataOutputStream(array);\n\n\t\t// ID\n\t\tout.writeShort(id);\n\t\t// Write Query Flags (recursion desired)\n\t\tout.writeShort(0x0100);\n\t\t// Question Count\n\t\tout.writeShort(0x0001);\n\t\t// Answer Record Count\n\t\tout.writeShort(0x0000);\n\t\t// Authority Record Count\n\t\tout.writeShort(0x0000);\n\t\t// Additional Record Count\n\t\tout.writeShort(0x0000);\n\n\t\t// Query Name\n\t\tvar domainParts = hostname.split(\"\\\\.\");\n\n\t\tfor (String domainPart : domainParts)\n\t\t{\n\t\t\tvar domainBytes = domainPart.getBytes(StandardCharsets.UTF_8);\n\t\t\tout.writeByte(domainBytes.length);\n\t\t\tout.write(domainBytes);\n\t\t}\n\t\tout.writeByte(0x00); // Terminator\n\n\t\t// Query Type 0x01 = A record (host addresses)\n\t\tout.writeShort(0x0001);\n\n\t\t// Query Class 0x01 = Internet Address\n\t\tout.writeShort(0x0001);\n\t}\n\n\tbyte[] toByteArray()\n\t{\n\t\treturn array.toByteArray();\n\t}\n\n\tpublic int getId()\n\t{\n\t\treturn id;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/dns/DnsResponse.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.dns;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.DataInputStream;\nimport java.io.IOException;\nimport java.net.InetAddress;\n\nclass DnsResponse\n{\n\tprivate final InetAddress address;\n\n\tDnsResponse(byte[] response, int id) throws IOException\n\t{\n\t\tvar input = new DataInputStream(new ByteArrayInputStream(response));\n\t\tvar receivedId = input.readShort();\n\t\tif (receivedId != id)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong ID, expected \" + id + \", got: \" + receivedId);\n\t\t}\n\t\tif ((input.readShort() & 0x8000) == 0)\n\t\t{\n\t\t\tthrow new IOException(\"Not a response\");\n\t\t}\n\t\tif (input.readShort() != 1)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong number of query\");\n\t\t}\n\t\tvar answers = input.readShort();\n\t\tif (answers != 1)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong number of answers, wanted: 1, got: \" + answers);\n\t\t}\n\t\tif (input.readShort() != 0)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong number of records\");\n\t\t}\n\t\tif (input.readShort() != 0)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong number of additional records\");\n\t\t}\n\n\t\t// Eat up the questions\n\t\tint recordCount;\n\t\twhile ((recordCount = input.readByte()) > 0)\n\t\t{\n\t\t\tfor (var i = 0; i < recordCount; i++)\n\t\t\t{\n\t\t\t\tinput.readByte();\n\t\t\t}\n\t\t}\n\t\tinput.readShort(); // Question type\n\t\tinput.readShort(); // Question class\n\t\tinput.readShort(); // Field\n\t\tif (input.readShort() != 1)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong type of answer\");\n\t\t}\n\t\tif (input.readShort() != 1)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong class of answer\");\n\t\t}\n\t\tinput.readInt(); // TTL\n\t\tif (input.readShort() != 4)\n\t\t{\n\t\t\tthrow new IOException(\"Wrong length\");\n\t\t}\n\n\t\tvar buf = new byte[4];\n\t\tfor (var i = 0; i < 4; i++)\n\t\t{\n\t\t\tbuf[i] = input.readByte();\n\t\t}\n\t\taddress = InetAddress.getByAddress(buf);\n\t}\n\n\tpublic InetAddress getAddress()\n\t{\n\t\treturn address;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/i2p/I2pAddress.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.i2p;\n\nimport java.util.regex.Pattern;\n\npublic final class I2pAddress\n{\n\tprivate static final Pattern I2P_B32_PATTERN = Pattern.compile(\"[a-z2-7]{52}\\\\.b32.i2p:\\\\d{1,5}\");\n\n\tprivate I2pAddress()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static boolean isValidAddress(String address)\n\t{\n\t\treturn I2P_B32_PATTERN.matcher(address).matches();\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/ip/IP.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.ip;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.net.*;\nimport java.util.Set;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.stream.IntStream;\n\n/**\n * IP handling utility class.\n */\npublic final class IP\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(IP.class);\n\n\t/**\n\t * List of port to avoid picking up as default because of their popularity in a NAT setup.\n\t * Xeres uses a range from 1025 to 32767.\n\t * Note that some ports aren't really popular, but they're scanned by default by some anti-viruses.\n\t */\n\tprivate static final Set<Integer> reservedPorts = Set.of(\n\t\t\t1080,  // Socks proxy\n\t\t\t1194,  // Open VPN\n\t\t\t1433,  // MS SQL\n\t\t\t1701,  // L2TP\n\t\t\t1723,  // PPTP VPN\n\t\t\t1900,  // SSDP\n\t\t\t2021,  // FTP ALG\n\t\t\t2041,  // Mail.ru\n\t\t\t2086,  // GNUnet\n\t\t\t2375,  // Docker\n\t\t\t2376,  // Docker (SSL)\n\t\t\t3074,  // XBox Live\n\t\t\t3128,  // Default proxy\n\t\t\t3306,  // MySQL\n\t\t\t3389,  // Remote Desktop Protocol\n\t\t\t4242,  // Quassel\n\t\t\t4444,  // I2P Proxy\n\t\t\t4500,  // IPSec\n\t\t\t5000,  // Yahoo!\n\t\t\t5001,  // Yahoo!\n\t\t\t5050,  // Yahoo!\n\t\t\t5101,  // Yahoo!\n\t\t\t5190,  // ICQ\n\t\t\t5060,  // Asterisk\n\t\t\t5061,  // Asterisk (SSL)\n\t\t\t5222,  // Jabber\n\t\t\t5223,  // Jabber\n\t\t\t5269,  // Jabber\n\t\t\t6232,  // Ourself\n\t\t\t6233,  // Ourself + 1\n\t\t\t6667,  // IRC\n\t\t\t6697,  // IRCS\n\t\t\t6881,  // Bittorrent\n\t\t\t6882,  // Bittorrent\n\t\t\t6883,  // Bittorrent\n\t\t\t6884,  // Bittorrent\n\t\t\t6885,  // Bittorrent\n\t\t\t6886,  // Bittorrent\n\t\t\t6887,  // Bittorrent\n\t\t\t6888,  // Bittorrent\n\t\t\t6889,  // Bittorrent\n\t\t\t7652,  // I2P\n\t\t\t7653,  // I2P\n\t\t\t7654,  // I2P\n\t\t\t7900,  // Many local tests\n\t\t\t8000,  // Many local tests\n\t\t\t8080,  // Many local tests\n\t\t\t8088,  // Many local tests\n\t\t\t8888,  // Many local tests\n\t\t\t9001,  // Tor\n\t\t\t9030,  // Tor\n\t\t\t9050,  // Tor\n\t\t\t9051,  // Tor\n\t\t\t9080,  // Logitech's LGHUB\n\t\t\t11523  // No idea why Kaspersky scans this\n\t);\n\n\tprivate static final int BINDING_ATTEMPTS_MAX = 100; // After that many failed attempts, there must be something wrong\n\n\tprivate IP()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Finds a free local port to bind to. There's a built-in blacklist of commonly used ports which are avoided.\n\t *\n\t * @return a free local port\n\t */\n\tpublic static int getFreeLocalPort()\n\t{\n\t\tint port;\n\t\tvar bindErrorDetector = 0;\n\n\t\twhile (true)\n\t\t{\n\t\t\t// Avoid Ephemeral ports, see https://en.wikipedia.org/wiki/Ephemeral_port\n\t\t\tport = ThreadLocalRandom.current().nextInt(1025, 32767);\n\n\t\t\tif (reservedPorts.contains(port))\n\t\t\t{\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\ttry (var socket = new Socket())\n\t\t\t{\n\t\t\t\tsocket.bind(new InetSocketAddress(\"0.0.0.0\", port));\n\t\t\t\treturn port;\n\t\t\t}\n\t\t\tcatch (IOException _)\n\t\t\t{\n\t\t\t\tif (bindErrorDetector > BINDING_ATTEMPTS_MAX)\n\t\t\t\t{\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t\tbindErrorDetector++;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Tries its best to get the local IP address, without requiring an external\n\t * server. Should work at all times unless the host has no TCP/IP stack.\n\t * <p>If the host has no internet access, then 127.0.0.1 is used.\n\t *\n\t * @return the local IP address or null\n\t */\n\tpublic static String getLocalIpAddress()\n\t{\n\t\tString ip;\n\n\t\ttry (var socket = new DatagramSocket())\n\t\t{\n\t\t\tsocket.connect(InetAddress.getByName(\"1.1.1.1\"), 10000);\n\t\t\tip = socket.getLocalAddress().getHostAddress();\n\t\t\tif (isBindableIp(ip))\n\t\t\t{\n\t\t\t\treturn ip;\n\t\t\t}\n\n\t\t\t// The above is reported to not work on MacOS, if so, just scan all interfaces manually.\n\t\t\tip = findIpFromInterfaces();\n\t\t\tif (isRoutableIp(ip))\n\t\t\t{\n\t\t\t\treturn ip;\n\t\t\t}\n\t\t}\n\t\tcatch (IOException | UncheckedIOException _)\n\t\t{\n\t\t\tip = null;\n\t\t}\n\t\treturn ip;\n\t}\n\n\t/**\n\t * Checks if the IP address can be bound to (that is, a server can run on it).\n\t *\n\t * @param ip the IP address to check\n\t * @return true if it's bindable\n\t */\n\tpublic static boolean isBindableIp(String ip)\n\t{\n\t\treturn isLanIp(ip) || isPublicIp(ip) || isLocalIp(ip);\n\t}\n\n\t/**\n\t * Checks if the IP address is routable, which means it's either a valid LAN address (for example, 192.168.1.4) or a public IP address.\n\t *\n\t * @param ip the IP address to check\n\t * @return true if it's routable\n\t */\n\tpublic static boolean isRoutableIp(String ip)\n\t{\n\t\treturn isLanIp(ip) || isPublicIp(ip);\n\t}\n\n\t/**\n\t * Checks if the IP address if from a LAN (that is, a privately routable IP address; for example, 192.168.1.4 or 10.0.0.5).\n\t *\n\t * @param ip the IP address to check\n\t * @return true if it's a LAN address\n\t */\n\tpublic static boolean isLanIp(String ip)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn isLanAddress(InetAddress.getByName(ip));\n\t\t}\n\t\tcatch (UnknownHostException _)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Checks if the IP address is a publicly routable IP address (that is, an IP that an Internet router will forward).\n\t *\n\t * @param ip the IP address to check\n\t * @return true if it's a public IP address\n\t */\n\tpublic static boolean isPublicIp(String ip)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn isPublicAddress(InetAddress.getByName(ip));\n\t\t}\n\t\tcatch (UnknownHostException _)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Checks if the IP address is a local IP (localhost or link local).\n\t *\n\t * @param ip the IP address to check\n\t * @return true if it's a local IP address\n\t */\n\tpublic static boolean isLocalIp(String ip)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn isLocalAddress(InetAddress.getByName(ip));\n\t\t}\n\t\tcatch (UnknownHostException _)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Try to find the local IP by iterating all interfaces.<br>\n\t * Note: this doesn't work in all cases (for example, if docker has some address like 10.0.75.1 then it might be\n\t * picked up before the proper interface).\n\t *\n\t * @return the IP address if found, otherwise null\n\t * @throws SocketException if there's a failure to get the interfaces\n\t */\n\tprivate static String findIpFromInterfaces() throws SocketException\n\t{\n\t\tvar interfaces = NetworkInterface.getNetworkInterfaces().asIterator();\n\t\twhile (interfaces.hasNext())\n\t\t{\n\t\t\tvar networkInterface = interfaces.next();\n\t\t\tif (networkInterface.isUp())\n\t\t\t{\n\t\t\t\tvar addresses = networkInterface.getInetAddresses().asIterator();\n\n\t\t\t\twhile (addresses.hasNext())\n\t\t\t\t{\n\t\t\t\t\tvar address = addresses.next();\n\n\t\t\t\t\tif (isRoutableAddress(address))\n\t\t\t\t\t{\n\t\t\t\t\t\tlog.debug(\"IP found using interface enumeration system: {}\", address.getHostAddress());\n\t\t\t\t\t\treturn address.getHostAddress();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate static boolean isRoutableAddress(InetAddress address)\n\t{\n\t\treturn isLanAddress(address) || isPublicAddress(address);\n\t}\n\n\tprivate static boolean isLanAddress(InetAddress address)\n\t{\n\t\treturn address.isSiteLocalAddress();\n\t}\n\n\tprivate static boolean isPublicAddress(InetAddress address)\n\t{\n\t\treturn !(isSpecifiedHostOnThisNetwork(address) || // 0.0.0.0 - 0.255.255.255\n\t\t\t\taddress.isLoopbackAddress() || // 127.0.0.0 - 127.255.255.255\n\t\t\t\taddress.isSiteLocalAddress() || // 10.0.0.0 - 10.255.255.255 | 172.16.0.0 - 172.31.255.255 | 192.0.0.0 - 192.0.0.255\n\t\t\t\tisSharedAddressSpace(address) || // 100.64.0.0 - 100.127.255.255\n\t\t\t\taddress.isLinkLocalAddress() || // 169.254.0.0 - 169.254.255.255\n\t\t\t\taddress.isMulticastAddress() || // 224.0.0.0 - 239.255.255.255\n\t\t\t\tisLimitedBroadcastAddress(address)); // 255.255.255.255\n\t}\n\n\tprivate static boolean isLocalAddress(InetAddress address)\n\t{\n\t\treturn address.isLinkLocalAddress() ||\n\t\t\t\taddress.isLoopbackAddress();\n\t}\n\n\tprivate static boolean isLimitedBroadcastAddress(InetAddress address)\n\t{\n\t\t// 255.255.255.255, see rfc6890\n\t\treturn IntStream.of(3, 2, 1, 0).allMatch(i -> address.getAddress()[i] == -1);\n\t}\n\n\t/**\n\t * Check if an address is a <i>current network</i> (0.0.0.0/8). It must not be sent except as a source\n\t * address as part of an initialization procedure by which the host learns its full IP address.<br>\n\t * Note: 0.0.0.0 (bind to any interface) is included as well. If you only need it, use {@link InetAddress#isAnyLocalAddress()} instead.\n\t *\n\t * @param address the address to test\n\t * @return true if the address represents a <i>current network</i>\n\t * @see <a href=\"https://tools.ietf.org/html/rfc6890\">rfc6890</a> and <a href=\"https://tools.ietf.org/html/rfc1122#page-29\">rfc1122 (section 3.2.1.3)</a>\n\t */\n\tprivate static boolean isSpecifiedHostOnThisNetwork(InetAddress address)\n\t{\n\t\treturn address.getAddress()[0] == 0;\n\t}\n\n\t/**\n\t * Check if an address is in a shared address space (100.64.0.0/10), which is used when the ISP\n\t * is using a carrier-grade NAT. This address cannot be reached from the public Internet directly.\n\t *\n\t * @param address the address to test\n\t * @return true if in a shared address space\n\t * @see <a href=\"https://tools.ietf.org/html/rfc6598\">rfc6598</a>\n\t */\n\tprivate static boolean isSharedAddressSpace(InetAddress address)\n\t{\n\t\treturn address.getAddress()[0] == 100 && Byte.toUnsignedInt(address.getAddress()[1]) >= 64 && Byte.toUnsignedInt(address.getAddress()[1]) < 128;\n\t}\n\n\t/**\n\t * Checks if a port is invalid (that is, cannot be bound to or sent to).\n\t *\n\t * @param port the port to check\n\t * @return true if valid\n\t */\n\tpublic static boolean isInvalidPort(int port)\n\t{\n\t\treturn port <= 0 || port >= 65536;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/ip/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * IP protocol support. Only IPv4 is supported for now.\n */\npackage io.xeres.common.protocol.ip;\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/tor/OnionAddress.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.tor;\n\nimport java.util.regex.Pattern;\n\npublic final class OnionAddress\n{\n\tprivate static final Pattern ONION_PATTERN = Pattern.compile(\"[a-z2-7]{56}\\\\.onion:\\\\d{1,5}\");\n\n\tprivate OnionAddress()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static boolean isValidAddress(String address)\n\t{\n\t\treturn ONION_PATTERN.matcher(address).matches();\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/tor/package-info.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/**\n * Tor protocol support.\n */\npackage io.xeres.common.protocol.tor;\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/protocol/xrs/RsServiceType.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.xrs;\n\nimport io.xeres.common.annotation.RsDeprecated;\n\n/**\n * The registry of Retroshare service types. Do not change their names, as they're also checked for matches.\n */\npublic enum RsServiceType\n{\n\tNONE(0, null, 0, 0, 0, 0),\n\n\t/**\n\t * The discovery service.\n\t */\n\tDISCOVERY(0x11, \"disc\", 1, 0, 1, 0),\n\n\t/**\n\t * The chat service.\n\t */\n\tCHAT(0x12, \"chat\", 1, 0, 1, 0),\n\n\t/**\n\t * The messaging service (direct mail, etc...).\n\t */\n\tMESSAGES(0x13, \"msg\", 1, 0, 1, 0),\n\n\t/**\n\t * The turtle service.\n\t */\n\tTURTLE_ROUTER(0x14, \"turtle\", 1, 0, 1, 0),\n\n\t@RsDeprecated\n\tTUNNEL(0x15, null, 1, 0, 1, 0),\n\n\t/**\n\t * The heartbeat service.\n\t */\n\tHEARTBEAT(0x16, \"heartbeat\", 1, 0, 1, 0),\n\n\t/**\n\t * The file transfer service.\n\t */\n\tFILE_TRANSFER(0x17, \"ft\", 1, 0, 1, 0),\n\n\t/**\n\t * The global router.\n\t */\n\tGLOBAL_ROUTER(0x18, \"Global Router\", 1, 0, 1, 0),\n\n\t/**\n\t * The file database transfer service.\n\t */\n\tFILE_DATABASE(0x19, \"file_database\", 1, 0, 1, 0),\n\n\t/**\n\t * The service info service.\n\t */\n\tSERVICE_INFO(0x20, \"serviceinfo\", 1, 0, 1, 0),\n\n\t/**\n\t * The bandwidth service.\n\t */\n\tBANDWIDTH_CONTROL(0x21, \"bandwidth_ctrl\", 1, 0, 1, 0),\n\n\t/**\n\t * Claims to be new but was never used somehow.\n\t */\n\t@RsDeprecated\n\tMAIL(0x22, null, 1, 0, 1, 0),\n\n\t/**\n\t * Direct mail messages to a location ID.\n\t */\n\tDIRECT_MAIL(0x23, \"msgdirect\", 1, 0, 1, 0),\n\n\t@RsDeprecated\n\tDISTANT_MAIL(0x24, null, 1, 0, 1, 0),\n\n\t@RsDeprecated\n\tGWEMAIL_MAIL(0x25, null, 1, 0, 1, 0),\n\n\t/**\n\t * RS uses it internally for saving which services are permitted or not to other users.\n\t */\n\tSERVICE_CONTROL(0x26, null, 1, 0, 1, 0),\n\n\t@RsDeprecated\n\tDISTANT_CHAT(0x27, null, 1, 0, 1, 0),\n\n\t/**\n\t * The GXS tunnel service.\n\t */\n\tGXS_TUNNELS(0x28, \"GxsTunnels\", 1, 0, 1, 0),\n\n\t/**\n\t * IP filter list exchange.\n\t */\n\tBANLIST(0x101, \"banlist\", 1, 0, 1, 0),\n\n\t/**\n\t * The status service.\n\t */\n\tSTATUS(0x102, \"status\", 1, 0, 1, 0),\n\n\t/**\n\t * RS has an optional standalone friend server, which dispatches friends on a Tor link.\n\t */\n\tFRIEND_SERVER(0x103, null, 1, 0, 1, 0),\n\n\t/**\n\t * Just a placeholder?\n\t */\n\tNXS(0x200, null, 1, 0, 1, 0),\n\n\t/**\n\t * The identity service.\n\t */\n\tGXS_IDENTITY(0x211, \"gxsid\", 1, 0, 1, 0),\n\n\t/**\n\t * Photo album, not finished.\n\t */\n\tGXS_PHOTO(0x212, \"gxsphoto\", 1, 0, 1, 0),\n\n\t/**\n\t * Wiki service.\n\t */\n\tGXS_WIKI(0x213, \"gxswiki\", 1, 0, 1, 0),\n\n\t/**\n\t * Twitter clone.\n\t */\n\tGXS_WIRE(0x214, \"gxswire\", 1, 0, 1, 0),\n\n\t/**\n\t * The forum service.\n\t */\n\tGXS_FORUMS(0x215, \"gxsforums\", 1, 0, 1, 0),\n\n\t/**\n\t * The board service.\n\t */\n\tGXS_BOARDS(0x216, \"gxsposted\", 1, 0, 1, 0),\n\n\t/**\n\t * The channel service.\n\t */\n\tGXS_CHANNELS(0x217, \"gxschannels\", 1, 0, 1, 0),\n\n\t/**\n\t * The GXS circles.\n\t */\n\tGXS_CIRCLES(0x218, \"gxscircle\", 1, 0, 1, 0),\n\n\t/**\n\t * Identity reputation transfer.\n\t */\n\tGXS_REPUTATION(0x219, \"gxsreputation\", 1, 0, 1, 0),\n\n\t@RsDeprecated\n\tGXS_RECOGN(0x220, null, 1, 0, 1, 0),\n\n\t/**\n\t * Asynchronous mail delivery on top of GXS. Can be used to send messages when offline.\n\t * In RS, was implemented by chat (was) and is implemented by distant mail.\n\t */\n\tGXS_MAILS(0x230, \"GXS Mails\", 1, 0, 1, 0),\n\n\t/**\n\t * Used internally by RS for serialization.\n\t */\n\tJSONAPI(0x240, null, 1, 0, 1, 0),\n\n\t/**\n\t * Used by RS for serialization.\n\t */\n\tFORUMS_CONFIG(0x315, null, 1, 0, 1, 0),\n\n\t/**\n\t * Used by RS for serialization.\n\t */\n\tPOSTED_CONFIG(0x316, null, 1, 0, 1, 0),\n\n\t/**\n\t * Used by RS for serialization.\n\t */\n\tCHANNELS_CONFIG(0x317, null, 1, 0, 1, 0),\n\n\t/**\n\t * Experimental Destination-Sequenced Distance Vector routing in RS. Disabled.\n\t */\n\tDSDV(0x1010, \"dsdv\", 1, 0, 1, 0),\n\n\t/**\n\t * The RTT service.\n\t */\n\tRTT(0x1011, \"rtt\", 1, 0, 1, 0),\n\n\t// plugins\n\tARADO_ID(0x2001, null, 1, 0, 1, 0),\n\tRETRO_CHESS(0x2002, \"RetroChess\", 1, 0, 1, 0),\n\tFEEDREADER(0x2003, \"FEEDREADER\", 1, 0, 1, 0),\n\n\t/**\n\t * The VoIP service. Implemented as a plugin in RS, built-in in Xeres.\n\t */\n\tVOIP(0xA021, \"VOIP\", 1, 0, 1, 0),\n\n\t/**\n\t * GXS distant sync. Implemented for channels in RS.\n\t */\n\tGXS_DISTANT_SYNC(0x2233, \"GxsNetTunnel\", 1, 0, 1, 0),\n\n\t// packet slicing\n\tPACKET_SLICING_PROBE(0xAABB, \"SlicingProbe\", 1, 0, 1, 0),\n\n\t// Nabu's experimental services\n\tZERO_RESERVE(0xBEEF, null, 1, 0, 1, 0),\n\tFIDO_GW(0xF1D0, null, 1, 0, 1, 0);\n\n\tprivate final int type;\n\tprivate final String name;\n\tprivate final short versionMajor;\n\tprivate final short versionMinor;\n\tprivate final short minVersionMajor;\n\tprivate final short minVersionMinor;\n\n\tpublic static RsServiceType fromName(String name)\n\t{\n\t\tfor (RsServiceType serviceType : RsServiceType.values())\n\t\t{\n\t\t\tif (serviceType.name().equalsIgnoreCase(name))\n\t\t\t{\n\t\t\t\treturn serviceType;\n\t\t\t}\n\t\t}\n\t\treturn NONE;\n\t}\n\n\tRsServiceType(int type, String name, int versionMajor, int versionMinor, int minVersionMajor, int minVersionMinor)\n\t{\n\t\tthis.type = type;\n\t\tthis.name = name;\n\t\tthis.versionMajor = (short) versionMajor;\n\t\tthis.versionMinor = (short) versionMinor;\n\t\tthis.minVersionMajor = (short) minVersionMajor;\n\t\tthis.minVersionMinor = (short) minVersionMinor;\n\t}\n\n\tpublic int getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic short getVersionMajor()\n\t{\n\t\treturn versionMajor;\n\t}\n\n\tpublic short getVersionMinor()\n\t{\n\t\treturn versionMinor;\n\t}\n\n\tpublic short getMinVersionMajor()\n\t{\n\t\treturn minVersionMajor;\n\t}\n\n\tpublic short getMinVersionMinor()\n\t{\n\t\treturn minVersionMinor;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/PathConfig.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest;\n\npublic final class PathConfig\n{\n\tprivate PathConfig()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static final String CONFIG_PATH = \"/api/v1/config\";\n\tpublic static final String PROFILES_PATH = \"/api/v1/profiles\";\n\tpublic static final String LOCATIONS_PATH = \"/api/v1/locations\";\n\tpublic static final String CONNECTIONS_PATH = \"/api/v1/connections\";\n\tpublic static final String NOTIFICATIONS_PATH = \"/api/v1/notifications\";\n\tpublic static final String CHAT_PATH = \"/api/v1/chat\";\n\tpublic static final String IDENTITIES_PATH = \"/api/v1/identities\";\n\tpublic static final String SETTINGS_PATH = \"/api/v1/settings\";\n\tpublic static final String GEOIP_PATH = \"/api/v1/geoip\";\n\tpublic static final String FORUMS_PATH = \"/api/v1/forums\";\n\tpublic static final String SHARES_PATH = \"/api/v1/shares\";\n\tpublic static final String FILES_PATH = \"/api/v1/files\";\n\tpublic static final String STATISTICS_PATH = \"/api/v1/statistics\";\n\tpublic static final String CONTACT_PATH = \"/api/v1/contacts\";\n\tpublic static final String BOARDS_PATH = \"/api/v1/boards\";\n\tpublic static final String CHANNELS_PATH = \"/api/v1/channels\";\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/board/UpdateBoardMessageReadRequest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.board;\n\npublic record UpdateBoardMessageReadRequest(long messageId, boolean read)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/channel/UpdateChannelMessageReadRequest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.channel;\n\npublic record UpdateChannelMessageReadRequest(long messageId, boolean read)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/chat/ChatRoomVisibility.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.chat;\n\npublic enum ChatRoomVisibility\n{\n\tPUBLIC,\n\tPRIVATE;\n\n\tpublic static ChatRoomVisibility fromSelection(int index)\n\t{\n\t\treturn ChatRoomVisibility.values()[index];\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/chat/CreateChatRoomRequest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.chat;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\n\npublic record CreateChatRoomRequest(\n\t\t@Schema(example = \"Cool Room\")\n\t\t@NotNull\n\t\tString name,\n\n\t\t@Schema(example = \"The coolest chat room ever\")\n\t\t@NotNull\n\t\tString topic,\n\n\t\t@NotNull\n\t\tChatRoomVisibility visibility,\n\n\t\tboolean signedIdentities\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/chat/DistantChatRequest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.chat;\n\nimport jakarta.validation.constraints.NotNull;\n\npublic record DistantChatRequest(\n\t\t@NotNull\n\t\tLong identityId\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/chat/InviteToChatRoomRequest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.chat;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\n\nimport java.util.Set;\n\npublic record InviteToChatRoomRequest(\n\t\t@NotNull\n\t\tLong chatRoomId,\n\n\t\t@Schema(example = \"[\\\"463652d6dec7497d3c10cfe5de036ecf\\\", \\\"68105c73086c99f7299023ce4f544511\\\"]\")\n\t\t@NotEmpty\n\t\tSet<String> locationIdentifiers\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/Capabilities.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\npublic final class Capabilities\n{\n\tpublic static final String AUTOSTART = \"autostart\";\n\n\tprivate Capabilities()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/HostnameResponse.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\npublic record HostnameResponse(String hostname)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/ImportRsFriendsResponse.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\npublic record ImportRsFriendsResponse(int success, int errors)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/IpAddressResponse.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\npublic record IpAddressResponse(String ip, Integer port)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/OwnIdentityRequest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport static io.xeres.common.dto.identity.IdentityConstants.NAME_LENGTH_MAX;\nimport static io.xeres.common.dto.identity.IdentityConstants.NAME_LENGTH_MIN;\n\npublic record OwnIdentityRequest(\n\t\t@NotNull\n\t\t@Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX)\n\t\t@Schema(example = \"SuperCoolIdentity\")\n\t\tString name,\n\n\t\tboolean anonymous\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/OwnLocationRequest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport static io.xeres.common.dto.location.LocationConstants.NAME_LENGTH_MAX;\nimport static io.xeres.common.dto.location.LocationConstants.NAME_LENGTH_MIN;\n\npublic record OwnLocationRequest(\n\t\t@NotNull\n\t\t@Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX, message = \"location must be between \" + NAME_LENGTH_MIN + \" and \" + NAME_LENGTH_MAX + \" characters.\")\n\t\t@Schema(example = \"SuperCoolLocation\")\n\t\tString name\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/OwnProfileRequest.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MAX;\nimport static io.xeres.common.dto.profile.ProfileConstants.NAME_LENGTH_MIN;\n\npublic record OwnProfileRequest(\n\t\t@NotNull\n\t\t@Size(min = NAME_LENGTH_MIN, max = NAME_LENGTH_MAX, message = \"profile must be between \" + NAME_LENGTH_MIN + \" and \" + NAME_LENGTH_MAX + \" characters.\")\n\t\t@Schema(example = \"SuperCoolNickname\")\n\t\tString name\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/UsernameResponse.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\npublic record UsernameResponse(String username)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/config/VerifyUpdateRequest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.config;\n\nimport jakarta.validation.constraints.NotNull;\n\npublic record VerifyUpdateRequest(\n\t\t@NotNull String filePath,\n\t\tbyte @NotNull [] signature\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/connection/ConnectionRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.connection;\n\npublic record ConnectionRequest(String locationIdentifier, int connectionIndex)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/contact/Contact.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.contact;\n\nimport io.xeres.common.location.Availability;\n\npublic record Contact(String name, long profileId, long identityId, Availability availability, boolean accepted)\n{\n\tpublic static final Contact EMPTY = new Contact(null, 0L, 0L, Availability.OFFLINE, false);\n\tpublic static final Contact OWN = new Contact(null, 1L, 1L, Availability.OFFLINE, true);\n\n\tpublic static Contact withName(Contact contact, String name)\n\t{\n\t\treturn new Contact(name, contact.profileId(), contact.identityId(), contact.availability(), contact.accepted());\n\t}\n\n\tpublic static Contact withAvailability(Contact contact, Availability availability)\n\t{\n\t\treturn new Contact(contact.name(), contact.profileId(), contact.identityId(), availability, contact.accepted());\n\t}\n\n\tpublic static Contact withIdentityId(Contact contact, long identityId)\n\t{\n\t\treturn new Contact(contact.name(), contact.profileId(), identityId, contact.availability(), contact.accepted());\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/file/AddDownloadRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.file;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\n\npublic record AddDownloadRequest(\n\t\tString name,\n\t\tlong size,\n\t\tSha1Sum hash,\n\t\tLocationIdentifier locationIdentifier\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/file/FileDownloadRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.file;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport jakarta.validation.constraints.NotNull;\n\npublic record FileDownloadRequest(\n\t\t@NotNull String name,\n\t\t@NotNull String hash,\n\t\tlong size,\n\t\tLocationIdentifier locationIdentifier\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/file/FileProgress.java",
    "content": "package io.xeres.common.rest.file;\n\npublic record FileProgress(long id, String name, long currentSize, long totalSize, String hash, boolean completed)\n{\n}"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/file/FileSearchRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.file;\n\nimport jakarta.validation.constraints.NotNull;\n\npublic record FileSearchRequest(\n\t\t@NotNull\n\t\tString name\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/file/FileSearchResponse.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.file;\n\npublic record FileSearchResponse(int id)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/forum/CreateForumMessageRequest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.forum;\n\nimport jakarta.validation.constraints.NotBlank;\n\npublic record CreateForumMessageRequest(\n\t\tlong forumId,\n\n\t\t@NotBlank(message = \"Title must not be empty\")\n\t\tString title,\n\n\t\t@NotBlank(message = \"Content must not be empty\")\n\t\tString content,\n\n\t\tlong parentId,\n\n\t\tlong originalId\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/forum/CreateOrUpdateForumGroupRequest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.forum;\n\nimport jakarta.validation.constraints.NotBlank;\n\npublic record CreateOrUpdateForumGroupRequest(\n\t\t@NotBlank(message = \"Name must not be empty\")\n\t\tString name,\n\n\t\t@NotBlank(message = \"Description must not be empty\")\n\t\tString description\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/forum/ForumPostRequest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.forum;\n\npublic record ForumPostRequest(\n\t\tlong forumId,\n\t\tlong replyToId,\n\t\tlong messageId\n)\n{\n\t@Override\n\tpublic String toString()\n\t{\n\t\t// This is used by the Window Manager to find the window by its unique title\n\t\treturn forumId + \",\" + replyToId + \",\" + messageId;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/forum/UpdateForumMessageReadRequest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.forum;\n\npublic record UpdateForumMessageReadRequest(long messageId, boolean read)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/geoip/CountryResponse.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.geoip;\n\npublic record CountryResponse(String isoCountry)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/location/RSIdResponse.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.location;\n\npublic record RSIdResponse(\n\t\tString name,\n\t\tString location,\n\t\tString rsId\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/Notification.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification;\n\nimport com.fasterxml.jackson.annotation.JsonSubTypes;\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.xeres.common.rest.notification.availability.AvailabilityChange;\nimport io.xeres.common.rest.notification.board.AddOrUpdateBoardGroups;\nimport io.xeres.common.rest.notification.board.AddOrUpdateBoardMessages;\nimport io.xeres.common.rest.notification.board.SetBoardGroupMessagesReadState;\nimport io.xeres.common.rest.notification.board.SetBoardMessageReadState;\nimport io.xeres.common.rest.notification.channel.AddOrUpdateChannelGroups;\nimport io.xeres.common.rest.notification.channel.AddOrUpdateChannelMessages;\nimport io.xeres.common.rest.notification.channel.SetChannelGroupMessagesReadState;\nimport io.xeres.common.rest.notification.channel.SetChannelMessageReadState;\nimport io.xeres.common.rest.notification.contact.AddOrUpdateContacts;\nimport io.xeres.common.rest.notification.contact.RemoveContacts;\nimport io.xeres.common.rest.notification.file.FileNotification;\nimport io.xeres.common.rest.notification.file.FileSearchNotification;\nimport io.xeres.common.rest.notification.file.FileTrendNotification;\nimport io.xeres.common.rest.notification.forum.AddOrUpdateForumGroups;\nimport io.xeres.common.rest.notification.forum.AddOrUpdateForumMessages;\nimport io.xeres.common.rest.notification.forum.SetForumGroupMessagesReadState;\nimport io.xeres.common.rest.notification.forum.SetForumMessageReadState;\nimport io.xeres.common.rest.notification.status.StatusNotification;\n\nimport static io.xeres.common.rest.notification.Notification.*;\n\n/**\n * Notification superclass. It's important to list all of its subclasses in it because the \"type\" field is used\n * by Jackson to know which subclass to deserialize from. Changing the strings names should be avoided as this could\n * break the API if there's a 3rd party client.\n */\n@JsonTypeInfo(\n\t\tuse = JsonTypeInfo.Id.NAME,\n\t\tinclude = JsonTypeInfo.As.PROPERTY,\n\t\tproperty = \"type\"\n)\n@JsonSubTypes({\n\t\t// Boards\n\t\t@JsonSubTypes.Type(value = AddOrUpdateBoardGroups.class, name = ADD_OR_UPDATE_BOARD_GROUPS),\n\t\t@JsonSubTypes.Type(value = AddOrUpdateBoardMessages.class, name = ADD_OR_UPDATE_BOARD_MESSAGES),\n\t\t@JsonSubTypes.Type(value = SetBoardGroupMessagesReadState.class, name = SET_BOARD_GROUP_MESSAGES_READ_STATE),\n\t\t@JsonSubTypes.Type(value = SetBoardMessageReadState.class, name = SET_BOARD_MESSAGES_READ_STATE),\n\t\t// Channels\n\t\t@JsonSubTypes.Type(value = AddOrUpdateChannelGroups.class, name = ADD_OR_UPDATE_CHANNEL_GROUPS),\n\t\t@JsonSubTypes.Type(value = AddOrUpdateChannelMessages.class, name = ADD_OR_UPDATE_CHANNEL_MESSAGES),\n\t\t@JsonSubTypes.Type(value = SetChannelGroupMessagesReadState.class, name = SET_CHANNEL_GROUP_MESSAGES_READ_STATE),\n\t\t@JsonSubTypes.Type(value = SetChannelMessageReadState.class, name = SET_CHANNEL_MESSAGES_READ_STATE),\n\t\t// Forums\n\t\t@JsonSubTypes.Type(value = AddOrUpdateForumGroups.class, name = ADD_OR_UPDATE_FORUM_GROUPS),\n\t\t@JsonSubTypes.Type(value = AddOrUpdateForumMessages.class, name = ADD_OR_UPDATE_FORUM_MESSAGES),\n\t\t@JsonSubTypes.Type(value = SetForumGroupMessagesReadState.class, name = SET_FORUM_GROUP_MESSAGES_READ_STATE),\n\t\t@JsonSubTypes.Type(value = SetForumMessageReadState.class, name = SET_FORUM_MESSAGES_READ_STATE),\n\t\t// Availability\n\t\t@JsonSubTypes.Type(value = AvailabilityChange.class, name = AVAILABILITY_CHANGE),\n\t\t// Contact\n\t\t@JsonSubTypes.Type(value = AddOrUpdateContacts.class, name = ADD_OR_UPDATE_CONTACTS),\n\t\t@JsonSubTypes.Type(value = RemoveContacts.class, name = REMOVE_CONTACTS),\n\t\t// File\n\t\t@JsonSubTypes.Type(value = FileNotification.class, name = FILE),\n\t\t@JsonSubTypes.Type(value = FileSearchNotification.class, name = FILE_SEARCH),\n\t\t@JsonSubTypes.Type(value = FileTrendNotification.class, name = FILE_TREND),\n\t\t// Status\n\t\t@JsonSubTypes.Type(value = StatusNotification.class, name = STATUS),\n})\npublic interface Notification\n{\n\tString ADD_OR_UPDATE_BOARD_GROUPS = \"add_or_update_board_groups\";\n\tString ADD_OR_UPDATE_BOARD_MESSAGES = \"add_or_update_board_messages\";\n\tString SET_BOARD_GROUP_MESSAGES_READ_STATE = \"set_board_group_messages_read_state\";\n\tString SET_BOARD_MESSAGES_READ_STATE = \"set_board_message_read_state\";\n\n\tString ADD_OR_UPDATE_CHANNEL_GROUPS = \"add_or_update_channel_groups\";\n\tString ADD_OR_UPDATE_CHANNEL_MESSAGES = \"add_or_update_channel_messages\";\n\tString SET_CHANNEL_GROUP_MESSAGES_READ_STATE = \"set_channel_group_messages_read_state\";\n\tString SET_CHANNEL_MESSAGES_READ_STATE = \"set_channel_message_read_state\";\n\n\tString ADD_OR_UPDATE_FORUM_GROUPS = \"add_or_update_forum_groups\";\n\tString ADD_OR_UPDATE_FORUM_MESSAGES = \"add_or_update_forum_messages\";\n\tString SET_FORUM_GROUP_MESSAGES_READ_STATE = \"set_forum_group_messages_read_state\";\n\tString SET_FORUM_MESSAGES_READ_STATE = \"set_forum_message_read_state\";\n\n\tString AVAILABILITY_CHANGE = \"availability_change\";\n\n\tString ADD_OR_UPDATE_CONTACTS = \"add_or_update_contacts\";\n\tString REMOVE_CONTACTS = \"remove_contacts\";\n\n\tString FILE = \"file\";\n\tString FILE_SEARCH = \"file_search\";\n\tString FILE_TREND = \"file_trend\";\n\tString STATUS = \"status\";\n\n\tString getType();\n\n\tdefault boolean ignoreDuplicates()\n\t{\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/availability/AvailabilityChange.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.availability;\n\nimport io.xeres.common.location.Availability;\n\npublic record AvailabilityChange(Availability availability, long profileId, String profileName, long locationId, String locationName) implements AvailabilityNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn AVAILABILITY_CHANGE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/availability/AvailabilityNotification.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.availability;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic sealed interface AvailabilityNotification extends Notification permits AvailabilityChange\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/board/AddOrUpdateBoardGroups.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.board;\n\nimport io.xeres.common.dto.board.BoardGroupDTO;\n\nimport java.util.List;\n\npublic record AddOrUpdateBoardGroups(List<BoardGroupDTO> boardGroups) implements BoardNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_BOARD_GROUPS;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/board/AddOrUpdateBoardMessages.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.board;\n\nimport io.xeres.common.dto.board.BoardMessageDTO;\n\nimport java.util.List;\n\npublic record AddOrUpdateBoardMessages(List<BoardMessageDTO> boardMessages) implements BoardNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_BOARD_MESSAGES;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/board/BoardNotification.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.board;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic sealed interface BoardNotification extends Notification permits AddOrUpdateBoardGroups, AddOrUpdateBoardMessages, SetBoardGroupMessagesReadState, SetBoardMessageReadState\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/board/SetBoardGroupMessagesReadState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.board;\n\npublic record SetBoardGroupMessagesReadState(long groupId, boolean read) implements BoardNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn SET_BOARD_GROUP_MESSAGES_READ_STATE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/board/SetBoardMessageReadState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.board;\n\npublic record SetBoardMessageReadState(long groupId, long messageId, boolean read) implements BoardNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn SET_BOARD_MESSAGES_READ_STATE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/channel/AddOrUpdateChannelGroups.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.channel;\n\nimport io.xeres.common.dto.channel.ChannelGroupDTO;\n\nimport java.util.List;\n\npublic record AddOrUpdateChannelGroups(List<ChannelGroupDTO> channelGroups) implements ChannelNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_CHANNEL_GROUPS;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/channel/AddOrUpdateChannelMessages.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.channel;\n\nimport io.xeres.common.dto.channel.ChannelMessageDTO;\n\nimport java.util.List;\n\npublic record AddOrUpdateChannelMessages(List<ChannelMessageDTO> channelMessages) implements ChannelNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_CHANNEL_MESSAGES;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/channel/ChannelNotification.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.channel;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic sealed interface ChannelNotification extends Notification permits AddOrUpdateChannelGroups, AddOrUpdateChannelMessages, SetChannelGroupMessagesReadState, SetChannelMessageReadState\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/channel/SetChannelGroupMessagesReadState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.channel;\n\npublic record SetChannelGroupMessagesReadState(long groupId, boolean read) implements ChannelNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn SET_CHANNEL_GROUP_MESSAGES_READ_STATE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/channel/SetChannelMessageReadState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.channel;\n\npublic record SetChannelMessageReadState(long groupId, long messageId, boolean read) implements ChannelNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn SET_CHANNEL_MESSAGES_READ_STATE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/contact/AddOrUpdateContacts.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.contact;\n\nimport io.xeres.common.rest.contact.Contact;\n\nimport java.util.List;\n\npublic record AddOrUpdateContacts(List<Contact> contacts) implements ContactNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_CONTACTS;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/contact/ContactNotification.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.contact;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic sealed interface ContactNotification extends Notification permits AddOrUpdateContacts, RemoveContacts\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/contact/RemoveContacts.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.contact;\n\nimport io.xeres.common.rest.contact.Contact;\n\nimport java.util.List;\n\npublic record RemoveContacts(List<Contact> contacts) implements ContactNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn REMOVE_CONTACTS;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/file/FileNotification.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.file;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic record FileNotification(FileNotificationAction action, String shareName, String scannedFile) implements Notification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn FILE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/file/FileNotificationAction.java",
    "content": "package io.xeres.common.rest.notification.file;\n\npublic enum FileNotificationAction\n{\n\tNONE,\n\tSTART_SCANNING,\n\tSTART_HASHING,\n\tSTOP_HASHING,\n\tSTOP_SCANNING\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/file/FileSearchNotification.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.file;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic record FileSearchNotification(int requestId, String name, long size, String hash) implements Notification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn FILE_SEARCH;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/file/FileTrendNotification.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.file;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic record FileTrendNotification(String senderName, String keywords) implements Notification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn FILE_TREND;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/forum/AddOrUpdateForumGroups.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.forum;\n\nimport io.xeres.common.dto.forum.ForumGroupDTO;\n\nimport java.util.List;\n\npublic record AddOrUpdateForumGroups(List<ForumGroupDTO> forumGroups) implements ForumNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_FORUM_GROUPS;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/forum/AddOrUpdateForumMessages.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.forum;\n\nimport io.xeres.common.dto.forum.ForumMessageDTO;\n\nimport java.util.List;\n\npublic record AddOrUpdateForumMessages(List<ForumMessageDTO> forumMessages) implements ForumNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn ADD_OR_UPDATE_FORUM_MESSAGES;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/forum/ForumNotification.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.forum;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic sealed interface ForumNotification extends Notification permits AddOrUpdateForumGroups, AddOrUpdateForumMessages, SetForumGroupMessagesReadState, SetForumMessageReadState\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/forum/SetForumGroupMessagesReadState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.forum;\n\npublic record SetForumGroupMessagesReadState(long groupId, boolean read) implements ForumNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn SET_FORUM_GROUP_MESSAGES_READ_STATE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/forum/SetForumMessageReadState.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.forum;\n\npublic record SetForumMessageReadState(long groupId, long messageId, boolean read) implements ForumNotification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn SET_FORUM_MESSAGES_READ_STATE;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/status/DhtInfo.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.status;\n\npublic record DhtInfo(DhtStatus dhtStatus, int numPeers, long receivedPackets, long receivedBytes, long sentPackets, long sentBytes, int keyCount, int itemCount)\n{\n\tpublic static DhtInfo fromStatus(DhtStatus dhtStatus)\n\t{\n\t\treturn new DhtInfo(dhtStatus, 0, 0, 0, 0, 0, 0, 0);\n\t}\n\n\tpublic static DhtInfo fromStats(int numPeers, long receivedPackets, long receivedBytes, long sentPackets, long sentBytes, int keyCount, int itemCount)\n\t{\n\t\treturn new DhtInfo(DhtStatus.RUNNING, numPeers, receivedPackets, receivedBytes, sentPackets, sentBytes, keyCount, itemCount);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/status/DhtStatus.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.status;\n\npublic enum DhtStatus\n{\n\tOFF,\n\tINITIALIZING,\n\tRUNNING\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/status/NatStatus.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.status;\n\npublic enum NatStatus\n{\n\tUNKNOWN,\n\tFIREWALLED,\n\tUPNP\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/notification/status/StatusNotification.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification.status;\n\nimport io.xeres.common.rest.notification.Notification;\n\npublic record StatusNotification(int currentUsers, int totalUsers, NatStatus natStatus, DhtInfo dhtInfo) implements Notification\n{\n\t@Override\n\tpublic String getType()\n\t{\n\t\treturn STATUS;\n\t}\n\n\t@Override\n\tpublic boolean ignoreDuplicates()\n\t{\n\t\treturn true;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/profile/ProfileKeyAttributes.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.profile;\n\npublic record ProfileKeyAttributes(int version, int keyAlgorithm, int keyBits, int signatureHash)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/profile/RsIdRequest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.profile;\n\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\npublic record RsIdRequest(\n\t\t@NotNull(message = \"Missing RS id\")\n\t\t@Size(min = LENGTH_MIN, max = LENGTH_MAX)\n\t\tString rsId\n)\n{\n\tprivate static final int LENGTH_MIN = 8;\n\tprivate static final int LENGTH_MAX = 16384;\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/share/TemporaryShareRequest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.share;\n\nimport jakarta.validation.constraints.NotBlank;\n\npublic record TemporaryShareRequest(@NotBlank String filePath)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/share/TemporaryShareResponse.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.share;\n\npublic record TemporaryShareResponse(String hash)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/share/UpdateShareRequest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.share;\n\nimport io.xeres.common.dto.share.ShareDTO;\n\nimport java.util.List;\n\npublic record UpdateShareRequest(\n\t\tList<ShareDTO> shares\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/statistics/DataCounterPeer.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.statistics;\n\npublic record DataCounterPeer(long id, String name, long sent, long received)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/statistics/DataCounterStatisticsResponse.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.statistics;\n\nimport java.util.List;\n\npublic record DataCounterStatisticsResponse(List<DataCounterPeer> peers)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/statistics/RttPeer.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.statistics;\n\npublic record RttPeer(long id, String name, long mean)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/statistics/RttStatisticsResponse.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.statistics;\n\nimport java.util.List;\n\npublic record RttStatisticsResponse(List<RttPeer> peers)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rest/statistics/TurtleStatisticsResponse.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.statistics;\n\npublic record TurtleStatisticsResponse(\n\t\tfloat forwardTotal,\n\t\tfloat dataUpload,\n\t\tfloat dataDownload,\n\t\tfloat tunnelRequestsUpload,\n\t\tfloat tunnelRequestsDownload,\n\t\tfloat searchRequestsUpload,\n\t\tfloat searchRequestsDownload,\n\t\tfloat totalUpload,\n\t\tfloat totalDownload\n)\n{\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/rsid/Type.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rsid;\n\npublic enum Type\n{\n\t/**\n\t * This accepts any ID and generates the best one.\n\t */\n\tANY,\n\n\t/**\n\t * A short invite is a shorter version of an ID which contains enough information\n\t * to connect to one node. Its usage is recommended.\n\t */\n\tSHORT_INVITE,\n\n\t/**\n\t * This is the legacy version of the ID which contains a full PGP key and allows\n\t * connecting to several nodes. Use short invites instead.\n\t */\n\tCERTIFICATE\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/tray/TrayNotificationType.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.tray;\n\npublic enum TrayNotificationType\n{\n\tBROADCAST,\n\tCONNECTION,\n\tDISCOVERY\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/ByteUnitUtils.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport io.xeres.common.i18n.I18nUtils;\n\nimport java.text.DecimalFormat;\nimport java.util.ResourceBundle;\n\n/**\n * In the beginning God created the computer. And the computer was without form, and void;\n * and darkness was upon the face of the silicon. And the Spirit of God moved upon the face of\n * the wafers. And God said, let there be bytes: and there were bytes. And God saw the bytes,\n * that they were good: and God divided the bytes by 1024.\n */\npublic final class ByteUnitUtils\n{\n\tprivate static final DecimalFormat df = new DecimalFormat(\"#.##\");\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tprivate ByteUnitUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Returns the number of bytes in their proper unit, from bytes to exabytes, with up to 2 decimals, except for KBs.\n\t *\n\t * @param bytes the number of bytes, must be a positive number\n\t * @return the bytes in their proper unit or \"invalid\" if a negative number was given as input\n\t */\n\tpublic static String fromBytes(long bytes)\n\t{\n\t\tif (bytes < 0)\n\t\t{\n\t\t\treturn bundle.getString(\"byte-unit.invalid\");\n\t\t}\n\t\tif (bytes < 1024 * 10)\n\t\t{\n\t\t\treturn bytes + \" \" + bundle.getString(\"byte-unit.bytes\");\n\t\t}\n\t\telse if (bytes < 1024 * 1024)\n\t\t{\n\t\t\treturn df.format(bytes / 1024) + \" \" + bundle.getString(\"byte-unit.kb\");\n\t\t}\n\t\telse if (bytes < 1024 * 1024 * 1024)\n\t\t{\n\t\t\treturn df.format(bytes / 1024.0 / 1024.0) + \" \" + bundle.getString(\"byte-unit.mb\");\n\t\t}\n\t\telse if (bytes < 1024L * 1024 * 1024 * 1024)\n\t\t{\n\t\t\treturn df.format(bytes / 1024.0 / 1024.0 / 1024.0) + \" \" + bundle.getString(\"byte-unit.gb\");\n\t\t}\n\t\telse if (bytes < 1024L * 1024 * 1024 * 1024 * 1024)\n\t\t{\n\t\t\treturn df.format(bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0) + \" \" + bundle.getString(\"byte-unit.tb\");\n\t\t}\n\t\telse if (bytes < 1024L * 1024 * 1024 * 1024 * 1024 * 1024)\n\t\t{\n\t\t\treturn df.format(bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0 / 1024.0) + \" \" + bundle.getString(\"byte-unit.pb\");\n\t\t}\n\t\treturn df.format(bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0 / 1024.0 / 1024.0) + \" \" + bundle.getString(\"byte-unit.eb\");\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/DebugUtils.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\n/**\n * Various utility functions to use when debugging.\n */\npublic final class DebugUtils\n{\n\tprivate DebugUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Waits for a certain time. Useful to simulate things like a network delay or a heavy computation.\n\t *\n\t * @param seconds the number of seconds to wait\n\t */\n\tpublic static void wait(int seconds)\n\t{\n\t\ttry\n\t\t{\n\t\t\tThread.sleep(seconds * 1000L);\n\t\t}\n\t\tcatch (InterruptedException _)\n\t\t{\n\t\t\tThread.currentThread().interrupt();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/ExecutorUtils.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\n\npublic final class ExecutorUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ExecutorUtils.class);\n\n\tprivate ExecutorUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ScheduledExecutorService createFixedRateExecutor(NoSuppressedRunnable command, long period)\n\t{\n\t\treturn createFixedRateExecutor(command, period, period);\n\t}\n\n\tpublic static ScheduledExecutorService createFixedRateExecutor(NoSuppressedRunnable command, long initialDelay, long period)\n\t{\n\t\tvar executorService = Executors.newSingleThreadScheduledExecutor();\n\n\t\texecutorService.scheduleAtFixedRate(command,\n\t\t\t\tinitialDelay,\n\t\t\t\tperiod,\n\t\t\t\tTimeUnit.SECONDS);\n\n\t\treturn executorService;\n\t}\n\n\tpublic static void cleanupExecutor(ScheduledExecutorService executorService)\n\t{\n\t\tif (executorService != null)\n\t\t{\n\t\t\texecutorService.shutdownNow();\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar success = executorService.awaitTermination(2, TimeUnit.SECONDS);\n\t\t\t\tif (!success)\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"Executor {} failed to terminate during the waiting period\", executorService);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (InterruptedException _)\n\t\t\t{\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/FileNameUtils.java",
    "content": "package io.xeres.common.util;\n\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.Optional;\nimport java.util.regex.Pattern;\n\npublic final class FileNameUtils\n{\n\tprivate static final Pattern EXTENSION = Pattern.compile(\"\\\\.(?=[^.]+$)\");\n\tprivate static final Pattern DOWNLOAD_COUNT = Pattern.compile(\"\\\\((\\\\d{1,3})\\\\)$\");\n\n\tprivate FileNameUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Renames the files in a way similar as Chromium.\n\t *\n\t * @param fileName the file name\n\t * @return the filename with (1) appended or incremented\n\t */\n\tpublic static String rename(String fileName)\n\t{\n\t\tif (StringUtils.isEmpty(fileName))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"File name cannot be empty\");\n\t\t}\n\n\t\tvar tokens = EXTENSION.split(fileName);\n\t\tif (tokens.length == 2)\n\t\t{\n\t\t\t// We have at least one extension, find out if it's\n\t\t\t// a .tar.gz style case so that we turn it into a\n\t\t\t// \"(1).tar.gz\" and not into a \".tar (1).gz\".\n\t\t\tif (tokens[1].length() == 2)\n\t\t\t{\n\t\t\t\tvar tarTokens = EXTENSION.split(tokens[0]);\n\t\t\t\tif (tarTokens.length == 2 && tarTokens[1].length() == 3)\n\t\t\t\t{\n\t\t\t\t\treturn increment(tarTokens[0]) + \".\" + tarTokens[1] + \".\" + tokens[1];\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn increment(tokens[0]) + \".\" + tokens[1];\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn increment(tokens[0]);\n\t\t}\n\t}\n\n\t/**\n\t * Gets the extension of a file name.\n\t *\n\t * @param fileName the file name\n\t * @return the extension, without its dot (for example \"exe\") or an empty optional if there's no extension\n\t */\n\tpublic static Optional<String> getExtension(String fileName)\n\t{\n\t\tvar tokens = EXTENSION.split(fileName);\n\t\tif (tokens.length == 2)\n\t\t{\n\t\t\treturn Optional.of(tokens[1]);\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tprivate static String increment(String input)\n\t{\n\t\tvar matcher = DOWNLOAD_COUNT.matcher(input);\n\t\tif (matcher.find())\n\t\t{\n\t\t\tvar count = Integer.parseInt(matcher.group(1));\n\t\t\treturn input.substring(0, input.length() - (matcher.group(1).length() + 2)) + \"(\" + ++count + \")\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn input + \" (1)\";\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/NoSuppressedRunnable.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport org.slf4j.LoggerFactory;\n\n/**\n * This interface should be used instead of Runnable for executors so that any\n * exception is printed. If it's a scheduled executor, it will also keep running.\n * <br>\n * Example:\n * {@snippet :\n * executorService.scheduleAtFixedRate((NoSuppressedRunnable) this::manageChatRooms, 10, 10, TimeUnit.SECONDS);\n *}\n */\n@FunctionalInterface\npublic interface NoSuppressedRunnable extends Runnable\n{\n\t@Override\n\tdefault void run()\n\t{\n\t\ttry\n\t\t{\n\t\t\tdoRun();\n\t\t}\n\t\tcatch (Exception e)\n\t\t{\n\t\t\tLoggerFactory.getLogger(NoSuppressedRunnable.class).error(\"Exception in executor {}: \", getClass().getSimpleName(), e);\n\t\t}\n\t}\n\n\tvoid doRun();\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/OsUtils.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport io.xeres.common.AppName;\nimport net.harawata.appdirs.AppDirsFactory;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.system.ApplicationHome;\n\nimport java.awt.*;\nimport java.io.*;\nimport java.lang.ProcessBuilder.Redirect;\nimport java.lang.management.ManagementFactory;\nimport java.nio.file.FileAlreadyExistsException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.regex.Pattern;\n\nimport static java.util.regex.Pattern.CASE_INSENSITIVE;\n\npublic final class OsUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(OsUtils.class);\n\n\tprivate static final String CASE_FILE_PREFIX = \"XeresFileSystemCaseDetectorFile\";\n\tprivate static final String CASE_FILE_EXTENSION = \"tmp\";\n\tprivate static final String LOG_FILE_NAME = \"xeres.log\";\n\n\tprivate static final Pattern INVALID_WINDOWS_FILE_CHARS = Pattern.compile(\"([\\\\\\\\/:*?\\\"<>|\\\\p{Cntrl}]|^nul$)\", CASE_INSENSITIVE);\n\tprivate static final Pattern INVALID_LINUX_FILE_CHARS = Pattern.compile(\"[\\\\x00/]\");\n\tprivate static final Pattern INVALID_MACOS_FILE_CHARS = Pattern.compile(\"[:/]\");\n\n\tprivate OsUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Checks if a file system is case-sensitive.\n\t *\n\t * @param path the directory path in the filesystem hierarchy. The location must be writable.\n\t * @return true if case-sensitive\n\t */\n\tpublic static boolean isFileSystemCaseSensitive(Path path)\n\t{\n\t\tObjects.requireNonNull(path);\n\t\tPath lowerFile;\n\t\tPath upperFile = null;\n\t\ttry\n\t\t{\n\t\t\tlowerFile = createFileSystemDetectionFile(path, false);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.warn(\"Couldn't write file for filesystem case detection: {}, using OS guess workaround\", e.getMessage());\n\t\t\treturn isOsCaseSensitive();\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tupperFile = createFileSystemDetectionFile(path, true);\n\t\t}\n\t\tcatch (FileAlreadyExistsException _)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't write second file for filesystem case detection: {}, shouldn't happen but using OS guess workaround anyway\", e.getMessage());\n\t\t\treturn isOsCaseSensitive();\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif (lowerFile != null)\n\t\t\t\t{\n\t\t\t\t\tFiles.deleteIfExists(lowerFile);\n\t\t\t\t}\n\t\t\t\tif (upperFile != null)\n\t\t\t\t{\n\t\t\t\t\tFiles.deleteIfExists(upperFile);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Error while deleting filesystem detection files: {}\", e.getMessage());\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\t/**\n\t * Executes a shell command and its arguments, for example:\n\t * <p>\n\t * <code>\n\t * shellExecute(\"ls\", \"-al\");\n\t * </code>\n\t * </p>\n\t *\n\t * @param args the command and its arguments\n\t * @return the resulting output, line by line (with a {@code \\n} separator at the end of each line), or a string starting with \"Error: \" and the message.\n\t */\n\tpublic static String shellExecute(String... args)\n\t{\n\t\tvar sb = new StringBuilder();\n\t\ttry\n\t\t{\n\t\t\tvar processBuilder = new ProcessBuilder(args);\n\t\t\tvar process = processBuilder.start();\n\t\t\ttry (var reader = new BufferedReader(new InputStreamReader(process.getInputStream())))\n\t\t\t{\n\t\t\t\tString line;\n\t\t\t\twhile ((line = reader.readLine()) != null)\n\t\t\t\t{\n\t\t\t\t\tsb.append(line).append(\"\\n\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\treturn \"Error: \" + e.getMessage();\n\t\t}\n\t\treturn sb.toString();\n\t}\n\n\t/**\n\t * Executes a shell command and its arguments, asynchronously.\n\t *\n\t * @param args the command and its arguments\n\t */\n\tpublic static void shellExecuteAsync(String... args)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar processBuilder = new ProcessBuilder(args);\n\t\t\tprocessBuilder.redirectOutput(Redirect.DISCARD);\n\t\t\tprocessBuilder.redirectError(Redirect.DISCARD);\n\n\t\t\tprocessBuilder.start();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new IllegalStateException(e);\n\t\t}\n\t}\n\n\t/**\n\t * Opens a file like if it was launched from a graphical shell (for example, by double-clicking on it).\n\t *\n\t * @param file the file to open\n\t * @throws IllegalStateException if the file doesn't exist, or the OS has troubles launching it\n\t * @throws IllegalArgumentException if the file is not a file (a directory, etc...)\n\t */\n\tpublic static void shellOpen(File file)\n\t{\n\t\tObjects.requireNonNull(file);\n\t\tif (!file.exists())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Couldn't open the file \" + file + \" because it doesn't exist\");\n\t\t}\n\n\t\tif (!file.isFile())\n\t\t{\n\t\t\tthrow new IllegalArgumentException(file + \" is not a file\");\n\t\t}\n\n\t\t// Since JDK 25.0.2, Desktop.getDesktop().open() no longer works with executables on Windows.\n\t\t// See https://github.com/openjdk/jdk/commit/eddbd359654cf6e2a437367461231ba37ee76918#r185592597 and\n\t\t// https://learn.microsoft.com/en-us/windows/win32/api/winsafer/nf-winsafer-saferiisexecutablefiletype\n\t\tif (isExecutable(file.getName()))\n\t\t{\n\t\t\tshellExecuteAsync(file.getAbsolutePath());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.OPEN))\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Desktop is not supported\");\n\t\t\t}\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tDesktop.getDesktop().open(file); // XXX: not well tested on Linux and macOS, especially regarding executables\n\t\t\t}\n\t\t\tcatch (IOException | UnsupportedOperationException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Couldn't open the file \" + file + \": \" + e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Opens the folder with the file selected.\n\t *\n\t * @param file the file to show in the folder\n\t * @throws IllegalStateException if the file doesn't exist, or the OS has troubles launching a file browser\n\t */\n\tpublic static void showInFolder(File file)\n\t{\n\t\tObjects.requireNonNull(file);\n\t\tif (!file.exists())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Couldn't show the folder of the file \" + file + \" because the later doesn't exist\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tDesktop.getDesktop().browseFileDirectory(file);\n\t\t}\n\t\tcatch (UnsupportedOperationException e)\n\t\t{\n\t\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tnew ProcessBuilder(\"explorer.exe\", \"/select,\", file.getCanonicalPath()).start();\n\t\t\t\t}\n\t\t\t\tcatch (IOException ex)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalStateException(\"Couldn't show the folder of the file \" + file + \": \" + ex.getMessage());\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Couldn't show the folder of the file \" + file + \": \" + e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Opens the directory in the file explorer and lists its content\n\t *\n\t * @param directory the directory\n\t */\n\tpublic static void showFolder(File directory)\n\t{\n\t\tObjects.requireNonNull(directory);\n\t\tif (!directory.exists())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Couldn't show the folder \" + directory + \" because it doesn't exist\");\n\t\t}\n\n\t\tif (!directory.isDirectory())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Couldn't show the folder \" + directory + \" because it is not a directory\");\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tDesktop.getDesktop().browseFileDirectory(directory); // This is not exactly what we want\n\t\t}\n\t\tcatch (UnsupportedOperationException e)\n\t\t{\n\t\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tnew ProcessBuilder(\"explorer.exe\", directory.getCanonicalPath()).start();\n\t\t\t\t}\n\t\t\t\tcatch (IOException ex)\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalStateException(\"Couldn't show the folder \" + directory + \": \" + ex.getMessage());\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Couldn't show the folder \" + directory + \": \" + e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Sanitizes a file name. Replaces non-valid characters by '_'.\n\t *\n\t * @param fileName the file name to sanitize\n\t * @return a sanitized version of the file name, or the original one if there's nothing to sanitize\n\t */\n\tpublic static String sanitizeFileName(String fileName)\n\t{\n\t\tObjects.requireNonNull(fileName);\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\t// Any Unicode except control characters, \\, /, :, *, ?, \", <, >, | and no spaces at the beginning\n\t\t\t// or the end. The Win32 API automatically removes a single period at the end.\n\t\t\t// Forget about the \"invalids\" CON, AUX, COM1...9, LPT1...9. Those are only restricted in a cmd.exe or by explorer.exe, but they are valid\n\t\t\t// file names (you can create them with PowerShell, for example). Only NUL is restricted.\n\t\t\treturn INVALID_WINDOWS_FILE_CHARS.matcher(fileName).replaceAll(\"_\").trim();\n\t\t}\n\t\telse if (SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\t// macOS is : and /\n\t\t\treturn INVALID_MACOS_FILE_CHARS.matcher(fileName).replaceAll(\"_\");\n\t\t}\n\t\telse // Assume the rest is Linux\n\t\t{\n\t\t\t// Linux is NUL and /\n\t\t\treturn INVALID_LINUX_FILE_CHARS.matcher(fileName).replaceAll(\"_\");\n\t\t}\n\t}\n\n\tprivate static Path createFileSystemDetectionFile(Path path, boolean upperCase) throws IOException\n\t{\n\t\tvar file = path.toFile();\n\t\tvar pid = ManagementFactory.getRuntimeMXBean().getPid();\n\t\tvar pathCaseFile = Path.of((upperCase ? CASE_FILE_PREFIX.toUpperCase(Locale.ROOT) : CASE_FILE_PREFIX.toLowerCase(Locale.ROOT)) + \"_\" + pid + \".\" + CASE_FILE_EXTENSION);\n\n\t\tif (file.isDirectory())\n\t\t{\n\t\t\treturn Files.createFile(path.resolve(pathCaseFile));\n\t\t}\n\t\telse if (file.isFile())\n\t\t{\n\t\t\treturn Files.createFile(path.resolveSibling(pathCaseFile));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Created path is not a directory nor a file\");\n\t\t}\n\t}\n\n\tprivate static boolean isOsCaseSensitive()\n\t{\n\t\tif (SystemUtils.IS_OS_LINUX)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\telse if (SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\telse if (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"OS is unsupported\");\n\t\t}\n\t}\n\n\t/**\n\t * Sets the security level of the file. This currently only works on Windows.\n\t *\n\t * @param path    the path of the file\n\t * @param trusted if true, the security zone is set to Trusted Site Zone, otherwise it's set to Internet Zone\n\t */\n\tpublic static void setFileSecurity(Path path, boolean trusted)\n\t{\n\t\tObjects.requireNonNull(path);\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\ttry (var ads = new RandomAccessFile(path + \":Zone.Identifier\", \"rw\")) // We can't use Path.of() here as it won't accept the ':'\n\t\t\t{\n\t\t\t\tbyte[] data = (\"[ZoneTransfer]\\r\\nZoneId=\" + (trusted ? \"2\" : \"3\") + \"\\r\\nHostUrl=about:internet\\r\\n\").getBytes();\n\t\t\t\tads.write(data);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.warn(\"Couldn't set security zone of file {}: {}\", path, e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Sets the visibility of the file.\n\t * <p>\n\t * Note: only works on Windows.\n\t *\n\t * @param path    the path of the file\n\t * @param visible true if the file must be visible (default when creating new files), false otherwise\n\t */\n\tpublic static void setFileVisible(Path path, boolean visible)\n\t{\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tFiles.setAttribute(path, \"dos:hidden\", !visible);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.warn(\"Couldn't set the visibility of file at {}: {}\", path, e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Gets the application home.\n\t *\n\t * @return the path where the application is installed\n\t */\n\tpublic static Path getApplicationHome()\n\t{\n\t\tvar home = new ApplicationHome(OsUtils.class);\n\t\treturn home.getDir().toPath().toAbsolutePath();\n\t}\n\n\t/**\n\t * Gets the OS system cache directory of the app.\n\t *\n\t * @return the cache directory\n\t */\n\tpublic static Path getCacheDir()\n\t{\n\t\treturn Path.of(AppDirsFactory.getInstance().getUserCacheDir(AppName.NAME, null, null));\n\t}\n\n\t/**\n\t * Gets the OS data directory of the app.\n\t *\n\t * @return the data directory\n\t */\n\tpublic static Path getDataDir()\n\t{\n\t\treturn Path.of(AppDirsFactory.getInstance().getUserDataDir(AppName.NAME, null, null, true));\n\t}\n\n\t/**\n\t * Gets the OS download directory of the app.\n\t *\n\t * @return the download directory\n\t */\n\tpublic static Path getDownloadDir()\n\t{\n\t\treturn Path.of(AppDirsFactory.getInstance().getUserDownloadsDir(null, null, null));\n\t}\n\n\t/**\n\t * Gets the location of the log file.\n\t *\n\t * @return the location of the logfile\n\t */\n\tpublic static Path getLogFile()\n\t{\n\t\tif (isInstalled())\n\t\t{\n\t\t\treturn Path.of(OsUtils.getDataDir().toString(), \"Logs\", LOG_FILE_NAME);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Assumes we're in portable mode and from CurrentDir\n\t\t\treturn Path.of(LOG_FILE_NAME);\n\t\t}\n\t}\n\n\t/**\n\t * Checks if we're installed in the system.\n\t *\n\t * @return true if we were installed by jpackage\n\t */\n\tpublic static boolean isInstalled()\n\t{\n\t\tvar appPath = System.getProperty(\"jpackage.app-path\");\n\t\treturn appPath != null && !appPath.isEmpty();\n\t}\n\n\tprivate static boolean isExecutable(String name)\n\t{\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\treturn name.toLowerCase(Locale.ROOT).endsWith(\".exe\") || name.endsWith(\".msi\");\n\t\t}\n\t\treturn false;\n\t}\n}\n\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/RemoteUtils.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\n/**\n * Some utility class to get remote information for the client.\n */\npublic final class RemoteUtils\n{\n\tprivate RemoteUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String getHostnameAndPort()\n\t{\n\t\treturn getHostname() + \":\" + getControlPort();\n\t}\n\n\tprivate static String getHostname()\n\t{\n\t\treturn System.getProperty(\"xrs.ui.address\", \"127.0.0.1\");\n\t}\n\n\tprivate static int getControlPort()\n\t{\n\t\treturn Integer.parseInt(System.getProperty(\"xrs.ui.port\", \"6232\"));\n\t}\n\n\tpublic static String getControlUrl()\n\t{\n\t\tif (\"true\".equals(System.getProperty(\"server.ssl.enabled\")))\n\t\t{\n\t\t\treturn \"https://\" + getHostnameAndPort();\n\t\t}\n\t\t//noinspection HttpUrlsUsage\n\t\treturn \"http://\" + getHostnameAndPort();\n\t}\n\n\t/**\n\t * Checks if we're running as a remote client. That is, we're connecting to\n\t * a remote location.\n\t *\n\t * @return true if we are a remote client\n\t */\n\tpublic static boolean isRemoteUiClient()\n\t{\n\t\treturn \"none\".equals(System.getProperty(\"spring.main.web-application-type\"));\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/SecureRandomUtils.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport java.security.SecureRandom;\nimport java.util.Collections;\nimport java.util.Objects;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\n/**\n * A utility class to get secure random numbers. Prefer this instead of using new SecureRandom() directly\n * as it's more efficient. If you don't need a secure random, use {@code ThreadLocalRandom.current()}.\n */\npublic final class SecureRandomUtils\n{\n\tprivate static final SecureRandom SECURE_RANDOM = new SecureRandom();\n\n\tprivate SecureRandomUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static short nextShort()\n\t{\n\t\treturn (short) SECURE_RANDOM.nextInt();\n\t}\n\n\tpublic static int nextInt()\n\t{\n\t\treturn SECURE_RANDOM.nextInt();\n\t}\n\n\tpublic static long nextLong()\n\t{\n\t\treturn SECURE_RANDOM.nextLong();\n\t}\n\n\tpublic static double nextDouble()\n\t{\n\t\treturn SECURE_RANDOM.nextDouble();\n\t}\n\n\tpublic static void nextBytes(byte[] bytes)\n\t{\n\t\tSECURE_RANDOM.nextBytes(bytes);\n\t}\n\n\tpublic static SecureRandom getGenerator()\n\t{\n\t\treturn SECURE_RANDOM;\n\t}\n\n\t/**\n\t * Creates a secure password consisting of alphanumerical characters in upper and lower case.\n\t *\n\t * @param password the byte array that will be filled in with a password. Between 1 and 512 bytes.\n\t */\n\tpublic static void nextPassword(char[] password)\n\t{\n\t\tObjects.requireNonNull(password);\n\t\tvar size = password.length;\n\t\tif (size == 0)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Password length must be at least 1\");\n\t\t}\n\t\tif (size > 512)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Password length must be less than or equal to 512\");\n\t\t}\n\n\t\tvar upperSize = size / 3;\n\t\tvar lowerSize = size / 3;\n\t\tsize -= lowerSize + upperSize;\n\t\tvar numberSize = size;\n\n\t\tvar passwordList = Stream.concat(getUpperCaseChars(upperSize),\n\t\t\t\t\t\tStream.concat(getLowerCaseChars(lowerSize),\n\t\t\t\t\t\t\t\tgetNumbers(numberSize)))\n\t\t\t\t.collect(Collectors.toList());\n\n\t\tCollections.shuffle(passwordList);\n\n\t\tfor (var i = 0; i < passwordList.size(); i++)\n\t\t{\n\t\t\tpassword[i] = passwordList.get(i);\n\t\t}\n\t}\n\n\tprivate static Stream<Character> getUpperCaseChars(int count)\n\t{\n\t\tvar upperChars = SECURE_RANDOM.ints(count, 65, 91);\n\t\treturn upperChars.mapToObj(data -> (char) data);\n\t}\n\n\tprivate static Stream<Character> getLowerCaseChars(int count)\n\t{\n\t\tvar lowerChars = SECURE_RANDOM.ints(count, 97, 123);\n\t\treturn lowerChars.mapToObj(data -> (char) data);\n\t}\n\n\tprivate static Stream<Character> getNumbers(int count)\n\t{\n\t\tvar lowerChars = SECURE_RANDOM.ints(count, 48, 58);\n\t\treturn lowerChars.mapToObj(data -> (char) data);\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/ThreadUtils.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.time.Duration;\n\npublic final class ThreadUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ThreadUtils.class);\n\n\tprivate ThreadUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void waitForThread(Thread thread)\n\t{\n\t\tif (thread == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tif (!thread.join(Duration.ofSeconds(5)))\n\t\t\t{\n\t\t\t\tlog.warn(\"Thread {} timed out\", thread.getName());\n\t\t\t}\n\t\t}\n\t\tcatch (InterruptedException e)\n\t\t{\n\t\t\tlog.error(\"Failed to wait for termination on thread {}: {}\", thread.getName(), e.getMessage(), e);\n\t\t\tThread.currentThread().interrupt();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/image/ImageUtils.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util.image;\n\nimport dev.mccue.imgscalr.Scalr;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.MediaType;\n\nimport javax.imageio.ImageIO;\nimport javax.imageio.ImageReader;\nimport java.awt.*;\nimport java.awt.image.BufferedImage;\nimport java.awt.image.IndexColorModel;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.util.Base64;\nimport java.util.Iterator;\nimport java.util.stream.IntStream;\n\n/**\n * Provides utility methods for working with images.\n */\npublic final class ImageUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ImageUtils.class);\n\n\tprivate static final String DATA_IMAGE_PNG_BASE_64 = \"data:image/png;base64,\";\n\tprivate static final String DATA_IMAGE_JPEG_BASE_64 = \"data:image/jpeg;base64,\";\n\n\tprivate static final byte[] JPEG_HEADER = new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff};\n\tprivate static final byte[] PNG_HEADER = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a};\n\tprivate static final byte[] GIF_HEADER = new byte[]{'G', 'I', 'F'};\n\tprivate static final byte[] RIFF_HEADER = new byte[]{'R', 'I', 'F', 'F'};\n\tprivate static final byte[] WEBP_SIGNATURE = new byte[]{'W', 'E', 'B', 'P'};\n\n\tprivate static final int MAX_PNG_QUALITY = 3;\n\tprivate static final int MIN_PNG_QUALITY = 2; // 1 is too CPU intensive and doesn't compress much more (around 2.5% better)\n\n\tprivate ImageUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Writes a buffered image as a PNG or JPEG data URL, depending on the needs,\n\t * that is, a transparent image will be PNG and the rest will be JPEG. A transparent\n\t * image that is effectively opaque will still result as a JPEG.\n\t *\n\t * @param bufferedImage the image\n\t * @param maximumSize   the maximum size of the image in bytes. If 0, no limit is applied.\n\t * @return the image as a PNG or JPEG data URL, or an empty string if the image couldn't be written.\n\t */\n\tpublic static String writeImage(BufferedImage bufferedImage, int maximumSize)\n\t{\n\t\tif (isTransparent(bufferedImage))\n\t\t{\n\t\t\treturn writeImageAsPngData(bufferedImage, maximumSize);\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn writeImageAsJpegData(bufferedImage, maximumSize);\n\t\t}\n\t}\n\n\t/**\n\t * Writes a buffered image as a PNG file. The image is optimized\n\t * trying to fit the size. If needed, the image is converted to indexed PNG or scaled.\n\t *\n\t * @param bufferedImage the buffered image\n\t * @param maximumSize   the maximum size of the image in bytes. If 0, no limit is applied.\n\t * @param outputStream  the output stream\n\t * @return true if the image could be written, false otherwise\n\t */\n\tpublic static boolean writeImageAsPng(BufferedImage bufferedImage, int maximumSize, OutputStream outputStream)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar out = new ByteArrayOutputStream();\n\t\t\tPngUtils.writeBufferedImageToPng(bufferedImage, out);\n\n\t\t\tout = compressPngWithVaryingQuality(out, maximumSize);\n\n\t\t\t// If still too big, try to convert to indexed PNG and then optimize it again\n\t\t\tif (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray()))\n\t\t\t{\n\t\t\t\tout = new ByteArrayOutputStream();\n\t\t\t\tbufferedImage = PngUtils.convertToIndexedPng(bufferedImage);\n\t\t\t\tPngUtils.writeBufferedImageToPng(bufferedImage, out);\n\n\t\t\t\tout = compressPng(out); // Indexed PNGs can't use varying qualities\n\t\t\t}\n\n\t\t\tif (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray()))\n\t\t\t{\n\t\t\t\tout = new ByteArrayOutputStream();\n\t\t\t\tbufferedImage = PngUtils.convertToIndexedPng(limitMaximumImageSize(bufferedImage, (int) (bufferedImage.getWidth() * bufferedImage.getHeight() * 0.75)));\n\n\t\t\t\tPngUtils.writeBufferedImageToPng(bufferedImage, out);\n\t\t\t\tout = compressPng(out); // Ditto\n\t\t\t}\n\n\t\t\tif (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray()))\n\t\t\t{\n\t\t\t\tout = new ByteArrayOutputStream();\n\t\t\t\tbufferedImage = PngUtils.convertToIndexedPng(limitMaximumImageSize(bufferedImage, (int) (bufferedImage.getWidth() * bufferedImage.getHeight() * 0.50)));\n\n\t\t\t\tPngUtils.writeBufferedImageToPng(bufferedImage, out);\n\t\t\t\tout = compressPng(out); // Ditto\n\t\t\t}\n\n\t\t\tif (canCompressionPossiblyBeImproved(maximumSize, out.toByteArray()))\n\t\t\t{\n\t\t\t\tlog.warn(\"Couldn't compress to PNG below the maximum size\");\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\toutputStream.write(out.toByteArray());\n\t\t\treturn true;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't save buffered image as PNG: {}\", e.getMessage());\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Writes a buffered image as a PNG data URL. The image is optimized\n\t * trying to fit the size. If needed, the image is converted to indexed PNG.\n\t *\n\t * @param bufferedImage the buffered image\n\t * @param maximumSize   the maximum size of the image in bytes. If 0, no limit is applied.\n\t * @return the image as a PNG data URL, or an empty string if the image couldn't be written.\n\t */\n\tpublic static String writeImageAsPngData(BufferedImage bufferedImage, int maximumSize)\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\t\tif (writeImageAsPng(bufferedImage, maximumSize, out))\n\t\t{\n\t\t\treturn DATA_IMAGE_PNG_BASE_64 + Base64.getEncoder().encodeToString(out.toByteArray());\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn \"\";\n\t\t}\n\t}\n\n\t/**\n\t * Writes an image as a JPEG file. The image is optimized and its quality reduced\n\t * until it fits the size.\n\t *\n\t * @param bufferedImage the image\n\t * @param maximumSize   the maximum size of the image in bytes. If 0, no limit is applied.\n\t * @param outputStream  the output stream\n\t * @return true if the image could be written, false otherwise\n\t */\n\tpublic static boolean writeImageAsJpeg(BufferedImage bufferedImage, int maximumSize, OutputStream outputStream)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar out = new ByteArrayOutputStream();\n\t\t\tvar quality = 0.7f;\n\t\t\tbyte[] array;\n\t\t\tbufferedImage = JpegUtils.stripAlphaIfNeeded(bufferedImage);\n\t\t\tdo\n\t\t\t{\n\t\t\t\tJpegUtils.writeBufferedImageToJpeg(bufferedImage, quality, out);\n\t\t\t\tarray = out.toByteArray();\n\t\t\t\tquality -= 0.1f;\n\t\t\t}\n\t\t\twhile (canCompressionPossiblyBeImproved(maximumSize, array, quality));\n\n\t\t\toutputStream.write(array);\n\n\t\t\treturn true;\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't save image as JPEG: {}\", e.getMessage());\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Writes an image as a JPEG data URL. The image is optimized and its quality reduced\n\t * until it fits the size.\n\t *\n\t * @param bufferedImage the image\n\t * @param maximumSize   the maximum size of the image in bytes. If 0, no limit is applied.\n\t * @return the image as a JPEG data URL, or an empty string if the image couldn't be written.\n\t */\n\tpublic static String writeImageAsJpegData(BufferedImage bufferedImage, int maximumSize)\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\t\tif (writeImageAsJpeg(bufferedImage, maximumSize, out))\n\t\t{\n\t\t\treturn DATA_IMAGE_JPEG_BASE_64 + Base64.getEncoder().encodeToString(out.toByteArray());\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn \"\";\n\t\t}\n\t}\n\n\t/**\n\t * Limits the size of an image by scaling it down. The aspect ratio is always preserved.\n\t * Uses a high quality incremental scaling algorithm.\n\t *\n\t * @param image       the image\n\t * @param maximumSize the maximum size of the image in total number of pixels\n\t * @return the scaled image\n\t */\n\tpublic static BufferedImage limitMaximumImageSize(BufferedImage image, int maximumSize)\n\t{\n\t\tvar width = image.getWidth();\n\t\tvar height = image.getHeight();\n\n\t\tvar size = width * height;\n\n\t\tif (size > maximumSize)\n\t\t{\n\t\t\tvar reductionRatio = Math.sqrt((double) maximumSize / size);\n\t\t\tvar destWidth = (int) (width * reductionRatio);\n\t\t\tvar destHeight = (int) (height * reductionRatio);\n\n\t\t\t// This uses incremental scaling, which is the best one\n\t\t\treturn Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, destWidth, destHeight);\n\t\t}\n\t\treturn image;\n\t}\n\n\t/**\n\t * Scales an image up or down. The aspect ratio is always preserved, thus the target width and height might be smaller than\n\t * the parameters given. Uses a high quality incremental scaling algorithm.\n\t *\n\t * @param image        the image\n\t * @param targetWidth  the target width\n\t * @param targetHeight the target height\n\t * @return the scaled image\n\t */\n\tpublic static BufferedImage setImageSize(BufferedImage image, int targetWidth, int targetHeight)\n\t{\n\t\tvar width = image.getWidth();\n\t\tvar height = image.getHeight();\n\n\t\t// Calculate the scaling factor to fit within target dimensions\n\t\tdouble scaleX = (double) targetWidth / width;\n\t\tdouble scaleY = (double) targetHeight / height;\n\n\t\t// Use the smaller scale to ensure the image fits within both dimensions\n\t\tdouble scale = Math.min(scaleX, scaleY);\n\n\t\t// Calculate the resulting dimensions\n\t\tint newWidth = (int) Math.round(width * scale);\n\t\tint newHeight = (int) Math.round(height * scale);\n\n\t\t// Apply the scaling using imgscalr\n\t\treturn Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, newWidth, newHeight);\n\t}\n\n\t/**\n\t * Scales an image up or down. The aspect ratio is always preserved. If the source image is not square, the\n\t * background is filled either by a transparent background or a white background depending on the source image.\n\t *\n\t * @param image    the image\n\t * @param sideSize the side size\n\t * @return the scaled image\n\t */\n\tpublic static BufferedImage setImageSquareAndFill(BufferedImage image, int sideSize)\n\t{\n\t\tvar scaledImage = setImageSize(image, sideSize, sideSize);\n\n\t\t// Determine if we need a transparent background\n\t\tboolean hasTransparency = isTransparent(image);\n\n\t\t// Create a new image with the specified side size\n\t\tint imageType = hasTransparency ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;\n\t\tvar result = new BufferedImage(sideSize, sideSize, imageType);\n\n\t\t// Create a graphics context to draw on the new image\n\t\tvar graphics = result.createGraphics();\n\n\t\t// Set background color based on transparency\n\t\tif (hasTransparency)\n\t\t{\n\t\t\t// For transparent backgrounds, we'll use alpha to create transparency\n\t\t\tgraphics.setComposite(AlphaComposite.Clear);\n\t\t\tgraphics.fillRect(0, 0, sideSize, sideSize);\n\t\t\tgraphics.setComposite(AlphaComposite.SrcOver);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// For non-transparent images, use white background\n\t\t\tgraphics.setColor(Color.WHITE);\n\t\t\tgraphics.fillRect(0, 0, sideSize, sideSize);\n\t\t}\n\n\t\t// Calculate position to center the scaled image\n\t\tint x = (sideSize - scaledImage.getWidth()) / 2;\n\t\tint y = (sideSize - scaledImage.getHeight()) / 2;\n\n\t\t// Draw the scaled image centered on the new image\n\t\tgraphics.drawImage(scaledImage, x, y, null);\n\n\t\t// Clean up\n\t\tgraphics.dispose();\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Scales an image up or down. The aspect ratio is always preserved. If the source image is not square, its\n\t * largest dimension is cropped.\n\t *\n\t * @param image    the image\n\t * @param sideSize the side size\n\t * @return the scaled image\n\t */\n\tpublic static BufferedImage setImageSquareAndCrop(BufferedImage image, int sideSize)\n\t{\n\t\t// Determine if we need a transparent background\n\t\tboolean hasTransparency = isTransparent(image);\n\n\t\tvar width = image.getWidth();\n\t\tvar height = image.getHeight();\n\n\t\t// Calculate the scaling factor to fit within target dimensions\n\t\tdouble scaleX = (double) sideSize / width;\n\t\tdouble scaleY = (double) sideSize / height;\n\n\t\t// Use the smaller scale to ensure the image fits within both dimensions\n\t\tdouble scale = Math.max(scaleX, scaleY);\n\n\t\t// Calculate the resulting dimensions\n\t\tint newWidth = (int) Math.round(width * scale);\n\t\tint newHeight = (int) Math.round(height * scale);\n\n\t\t// Apply the scaling using imgscalr\n\t\tvar scaledImage = Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, newWidth, newHeight);\n\n\t\t// Create a new image with the specified side size\n\t\tint imageType = hasTransparency ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;\n\t\tvar result = new BufferedImage(sideSize, sideSize, imageType);\n\n\t\t// Create a graphics context to draw on the new image\n\t\tvar graphics = result.createGraphics();\n\n\t\t// Calculate position to center the scaled image\n\t\tint x = (sideSize - scaledImage.getWidth()) / 2;\n\t\tint y = (sideSize - scaledImage.getHeight()) / 2;\n\n\t\t// Draw the scaled image centered on the new image\n\t\tgraphics.drawImage(scaledImage, x, y, null);\n\n\t\t// Clean up\n\t\tgraphics.dispose();\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Detects the format of the image without decoding the whole.\n\t * <p>\n\t * Currently supported:\n\t * <ul>\n\t *     <li>PNG</li>\n\t *     <li>JPEG</li>\n\t *     <li>GIF</li>\n\t *     <li>WebP</li>\n\t * </ul>\n\t *\n\t * @param image the byte array containing the image data\n\t * @return the {@link MediaType} of the image or null if unknown\n\t */\n\tpublic static MediaType getImageMimeType(byte[] image)\n\t{\n\t\tif (image == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tif (isStartingWith(PNG_HEADER, image))\n\t\t{\n\t\t\treturn MediaType.IMAGE_PNG;\n\t\t}\n\t\telse if (isStartingWith(JPEG_HEADER, image))\n\t\t{\n\t\t\treturn MediaType.IMAGE_JPEG;\n\t\t}\n\t\telse if (isStartingWith(GIF_HEADER, image))\n\t\t{\n\t\t\treturn MediaType.IMAGE_GIF;\n\t\t}\n\t\telse if (isStartingWith(RIFF_HEADER, image) && contains(WEBP_SIGNATURE, 8, image))\n\t\t{\n\t\t\treturn MediaType.parseMediaType(\"image/webp\");\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Gets an image dimension without decoding the image data.\n\t *\n\t * @param inputStream the input stream\n\t * @return the image dimension or null if there was an error\n\t */\n\tpublic static Dimension getImageDimension(InputStream inputStream)\n\t{\n\t\ttry (var in = ImageIO.createImageInputStream(inputStream))\n\t\t{\n\t\t\tIterator<ImageReader> readers = ImageIO.getImageReaders(in);\n\t\t\tif (readers.hasNext())\n\t\t\t{\n\t\t\t\tImageReader reader = readers.next();\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\treader.setInput(in);\n\t\t\t\t\tint width = reader.getWidth(0);\n\t\t\t\t\tint height = reader.getHeight(0);\n\t\t\t\t\treturn new Dimension(width, height);\n\t\t\t\t}\n\t\t\t\tfinally\n\t\t\t\t{\n\t\t\t\t\treader.dispose();\n\t\t\t\t}\n\t\t\t}\n\t\t\tlog.warn(\"Unsupported image format\");\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.warn(\"Invalid image file: {}\", e.getMessage());\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Finds out if a media format is possibly transparent. This focuses more on the intent\n\t * of the format and is thus unreliable because some formats support both modes.\n\t *\n\t * @param contentType the MIME content type (for example \"image/jpeg\")\n\t * @return true if possibly transparent\n\t */\n\tpublic static boolean isPossiblyTransparent(String contentType)\n\t{\n\t\treturn MediaType.IMAGE_PNG_VALUE.equals(contentType) ||\n\t\t\t\tMediaType.IMAGE_GIF_VALUE.equals(contentType) ||\n\t\t\t\t\"image/webp\".equals(contentType) ||\n\t\t\t\t\"image/svg+xml\".equals(contentType) ||\n\t\t\t\t\"image/x-icon\".equals(contentType);\n\t}\n\n\tprivate static ByteArrayOutputStream compressPngWithVaryingQuality(ByteArrayOutputStream input, int maximumSize) throws IOException\n\t{\n\t\tvar quality = MAX_PNG_QUALITY;\n\n\t\tByteArrayOutputStream newOut;\n\t\tdo\n\t\t{\n\t\t\tnewOut = new ByteArrayOutputStream();\n\t\t\tPngUtils.optimizePng(input.toByteArray(), quality, newOut);\n\t\t\tlog.debug(\"quality: {}, size: {}, maximumSize: {}\", quality, newOut.size(), maximumSize);\n\t\t\tquality -= 1;\n\t\t}\n\t\twhile (canCompressionPossiblyBeImproved(maximumSize, newOut.toByteArray(), quality));\n\t\treturn newOut;\n\t}\n\n\tprivate static ByteArrayOutputStream compressPng(ByteArrayOutputStream input) throws IOException\n\t{\n\t\tvar out = new ByteArrayOutputStream();\n\t\tPngUtils.optimizePng(input.toByteArray(), MAX_PNG_QUALITY, out);\n\t\treturn out;\n\t}\n\n\tprivate static boolean isStartingWith(byte[] header, byte[] image)\n\t{\n\t\treturn contains(header, 0, image);\n\t}\n\n\tprivate static boolean contains(byte[] signature, int offset, byte[] image)\n\t{\n\t\treturn image.length >= signature.length + offset && IntStream.range(offset, signature.length).allMatch(i -> signature[i] == image[i]);\n\t}\n\n\tprivate static boolean canCompressionPossiblyBeImproved(int maximumSize, byte[] array, float quality)\n\t{\n\t\treturn maximumSize != 0 && Math.ceil((double) array.length / 3) * 4 > maximumSize - 200 && quality >= MIN_PNG_QUALITY; // 200 bytes to be safe as the message might contain tags and so on\n\t}\n\n\tprivate static boolean canCompressionPossiblyBeImproved(int maximumSize, byte[] array)\n\t{\n\t\treturn canCompressionPossiblyBeImproved(maximumSize, array, MAX_PNG_QUALITY);\n\t}\n\n\tprivate static boolean isTransparent(BufferedImage bufferedImage)\n\t{\n\t\tvar cm = bufferedImage.getColorModel();\n\n\t\t// IndexColorModel may define a single transparent palette index\n\t\tif (cm instanceof IndexColorModel icm)\n\t\t{\n\t\t\tif (icm.getTransparentPixel() != -1)\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\t// if no transparent palette index and no alpha, it's opaque\n\t\t\tif (!icm.hasAlpha())\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// If color model reports no alpha channel, image is opaque\n\t\t\tif (!cm.hasAlpha())\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\t// Now check pixel alpha values. Use a single getRGB call for speed.\n\t\tint w = bufferedImage.getWidth();\n\t\tint h = bufferedImage.getHeight();\n\t\tint totalPixels = w * h;\n\t\tint transparentCount = 0;\n\t\tint[] pixels = bufferedImage.getRGB(0, 0, w, h, null, 0, w);\n\t\tfor (int argb : pixels)\n\t\t{\n\t\t\tint alpha = (argb >>> 24) & 0xff;\n\t\t\tif (alpha != 0xff)\n\t\t\t{\n\t\t\t\ttransparentCount++;\n\t\t\t}\n\t\t}\n\t\t// If more than 5% is transparent, then we are transparent. Windows' snap tool\n\t\t// loves to add useless transparent borders.\n\t\treturn (double) transparentCount / totalPixels > 0.05;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/image/JpegUtils.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util.image;\n\nimport javax.imageio.IIOImage;\nimport javax.imageio.ImageIO;\nimport javax.imageio.ImageWriteParam;\nimport java.awt.*;\nimport java.awt.image.BufferedImage;\nimport java.io.IOException;\nimport java.io.OutputStream;\n\n/**\n * This class contains private utility methods for working with JPEG images.\n */\nfinal class JpegUtils\n{\n\tprivate JpegUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic void writeBufferedImageToJpeg(BufferedImage image, float quality, OutputStream outputStream) throws IOException\n\t{\n\t\tvar jpegWriter = ImageIO.getImageWritersByFormatName(\"JPEG\").next();\n\t\tvar jpegWriteParam = jpegWriter.getDefaultWriteParam();\n\t\tjpegWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);\n\t\tjpegWriteParam.setCompressionQuality(quality);\n\n\t\tvar ios = ImageIO.createImageOutputStream(outputStream);\n\t\tjpegWriter.setOutput(ios);\n\t\tvar outputImage = new IIOImage(image, null, null);\n\t\tjpegWriter.write(null, outputImage, jpegWriteParam);\n\t\tjpegWriter.dispose();\n\t}\n\n\tstatic BufferedImage stripAlphaIfNeeded(BufferedImage originalImage)\n\t{\n\t\tif (originalImage.getTransparency() == Transparency.OPAQUE)\n\t\t{\n\t\t\treturn originalImage;\n\t\t}\n\n\t\tvar w = originalImage.getWidth();\n\t\tvar h = originalImage.getHeight();\n\t\tvar newImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);\n\t\tvar rgb = originalImage.getRGB(0, 0, w, h, null, 0, w);\n\t\tnewImage.setRGB(0, 0, w, h, rgb, 0, w);\n\t\treturn newImage;\n\t}\n}\n"
  },
  {
    "path": "common/src/main/java/io/xeres/common/util/image/PngUtils.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util.image;\n\nimport com.googlecode.pngtastic.core.PngImage;\nimport com.googlecode.pngtastic.core.PngOptimizer;\n\nimport javax.imageio.IIOImage;\nimport javax.imageio.ImageIO;\nimport javax.imageio.ImageWriteParam;\nimport java.awt.image.BufferedImage;\nimport java.awt.image.ComponentColorModel;\nimport java.awt.image.IndexColorModel;\nimport java.awt.image.PackedColorModel;\nimport java.io.IOException;\nimport java.io.OutputStream;\nimport java.util.ArrayList;\nimport java.util.Comparator;\nimport java.util.List;\n\n/**\n * This class contains private utility methods for working with PNG images.\n */\nfinal class PngUtils\n{\n\tprivate PngUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tstatic BufferedImage convertToIndexedPng(BufferedImage image)\n\t{\n\t\tvar indexedImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_INDEXED, getOrCreateIndexedColorModel(image));\n\t\t// Using drawImage with an indexed color model always produces dithering which looks ugly for stickers, so we copy manually\n\t\tfor (var x = 0; x < image.getWidth(); x++)\n\t\t{\n\t\t\tfor (var y = 0; y < image.getHeight(); y++)\n\t\t\t{\n\t\t\t\tindexedImage.setRGB(x, y, image.getRGB(x, y));\n\t\t\t}\n\t\t}\n\t\treturn indexedImage;\n\t}\n\n\t/**\n\t * Creates or uses an indexed color model for the given image.\n\t *\n\t * @param image the image\n\t * @return and indexed color model\n\t */\n\tprivate static IndexColorModel getOrCreateIndexedColorModel(BufferedImage image)\n\t{\n\t\tvar colorModel = image.getColorModel();\n\n\t\tif (colorModel instanceof IndexColorModel indexColorModel)\n\t\t{\n\t\t\treturn indexColorModel;\n\t\t}\n\t\telse if (colorModel instanceof PackedColorModel || colorModel instanceof ComponentColorModel)\n\t\t{\n\t\t\treturn createOptimizedPalette(image, 256);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Unsupported color model: \" + colorModel.getClass().getSimpleName());\n\t\t}\n\t}\n\n\t/**\n\t * Creates an optimized palette for the given image.\n\t * Uses a median-cut algorithm to create a palette with the given number of colors.\n\t * Transparency is preserved, but only one level is used, so it can still give\n\t * some rough edges.\n\t *\n\t * @param image       the image\n\t * @param paletteSize the size of the palette. Should be a power of 2 or colors will be wasted.\n\t * @return the optimized palette\n\t */\n\tstatic IndexColorModel createOptimizedPalette(BufferedImage image, int paletteSize)\n\t{\n\t\tif (paletteSize < 1 || paletteSize > 256)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Palette size must be between 1 and 256\");\n\t\t}\n\n\t\t// Extract ARGB pixels and check if we have transparency\n\t\tint width = image.getWidth();\n\t\tint height = image.getHeight();\n\t\tvar argbPixels = new int[width * height];\n\t\timage.getRGB(0, 0, width, height, argbPixels, 0, width);\n\n\t\tList<int[]> opaquePixels = new ArrayList<>();\n\t\tvar hasTransparency = false;\n\n\t\tfor (int argb : argbPixels)\n\t\t{\n\t\t\tint a = (argb >> 24) & 0xff;\n\t\t\tif (a < 128)\n\t\t\t{\n\t\t\t\thasTransparency = true;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\topaquePixels.add(new int[]{\n\t\t\t\t\t\t(argb >> 16) & 0xff, // R\n\t\t\t\t\t\t(argb >> 8) & 0xff,  // G\n\t\t\t\t\t\targb & 0xff          // B\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\t// Adjust palette size for transparency\n\t\tint colorSlots = hasTransparency ? paletteSize - 1 : paletteSize;\n\t\tcolorSlots = Math.max(1, colorSlots);  // Ensure at least 1 color\n\n\t\tList<int[]> palette = medianCut(opaquePixels, colorSlots);\n\n\t\t// Create IndexColorModel\n\t\tvar r = new byte[paletteSize];\n\t\tvar g = new byte[paletteSize];\n\t\tvar b = new byte[paletteSize];\n\t\tvar a = new byte[paletteSize];\n\n\t\t// Set transparent entry if needed\n\t\tvar firstColorIdx = 0;\n\t\tif (hasTransparency)\n\t\t{\n\t\t\tfirstColorIdx = 1; // The first color is transparent\n\t\t}\n\n\t\t// Fill palette colors\n\t\tfor (var i = 0; i < palette.size(); i++)\n\t\t{\n\t\t\tint idx = firstColorIdx + i;\n\t\t\tif (idx >= paletteSize)\n\t\t\t{\n\t\t\t\tbreak;  // Safety check\n\t\t\t}\n\n\t\t\tint[] color = palette.get(i);\n\t\t\tr[idx] = (byte) color[0];\n\t\t\tg[idx] = (byte) color[1];\n\t\t\tb[idx] = (byte) color[2];\n\t\t\ta[idx] = (byte) 0xff;  // Opaque\n\t\t}\n\n\t\treturn new IndexColorModel(\n\t\t\t\tInteger.SIZE - Integer.numberOfLeadingZeros(paletteSize - 1),\n\t\t\t\tpaletteSize,\n\t\t\t\tr, g, b, a\n\t\t);\n\t}\n\n\t/**\n\t * Creates a palette by performing a median-cut algorithm, given all pixels from an image:\n\t * <ul>\n\t *     <li>Find the color channel (R, G, or B) with the greatest range\n\t *     <li>Sort pixels by that channel and split them into two buckets at the median\n\t *     <li>Repeat recursively until the desired number of buckets (colors) is reached\n\t *     <li>Average each bucket to create the final palette\n\t * </ul>\n\t * <p>\n\t *\n\t * @param pixels    a list of pixels\n\t * @param maxColors the maximum number of colors in the palette\n\t * @return the palette\n\t */\n\tprivate static List<int[]> medianCut(List<int[]> pixels, int maxColors)\n\t{\n\t\tList<List<int[]>> buckets = new ArrayList<>();\n\t\tbuckets.add(pixels);\n\n\t\twhile (buckets.size() < maxColors)\n\t\t{\n\t\t\tList<List<int[]>> newBuckets = new ArrayList<>();\n\t\t\tfor (List<int[]> bucket : buckets)\n\t\t\t{\n\t\t\t\tif (bucket.size() <= 1)\n\t\t\t\t{\n\t\t\t\t\tnewBuckets.add(bucket);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tint channel = findChannel(bucket);\n\n\t\t\t\tbucket.sort(Comparator.comparingInt(pixel -> pixel[channel]));\n\t\t\t\tint median = bucket.size() / 2;\n\t\t\t\tnewBuckets.add(bucket.subList(0, median));\n\t\t\t\tnewBuckets.add(bucket.subList(median, bucket.size()));\n\t\t\t}\n\t\t\tbuckets = newBuckets;\n\t\t}\n\n\t\t// Average buckets to create a palette\n\t\tList<int[]> palette = new ArrayList<>();\n\t\tfor (List<int[]> bucket : buckets)\n\t\t{\n\t\t\tif (bucket.isEmpty())\n\t\t\t{\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tpalette.add(getAverage(bucket));\n\t\t}\n\t\treturn palette;\n\t}\n\n\t/**\n\t * Finds the channel with the greatest range.\n\t *\n\t * @param bucket the bucket of pixels\n\t * @return the channel with the greatest range (0, 1 or 2)\n\t */\n\tprivate static int findChannel(List<int[]> bucket)\n\t{\n\t\tint[] min = {255, 255, 255};\n\t\tint[] max = {0, 0, 0};\n\t\tfor (int[] pixel : bucket)\n\t\t{\n\t\t\tfor (var i = 0; i < 3; i++)\n\t\t\t{\n\t\t\t\tmin[i] = Math.min(min[i], pixel[i]);\n\t\t\t\tmax[i] = Math.max(max[i], pixel[i]);\n\t\t\t}\n\t\t}\n\n\t\t// Sort and split\n\t\tif (max[0] - min[0] >= max[1] - min[1])\n\t\t{\n\t\t\tif (max[0] - min[0] >= max[2] - min[2])\n\t\t\t{\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn 2;\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (max[1] - min[1] >= max[2] - min[2])\n\t\t\t{\n\t\t\t\treturn 1;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn 2;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static int[] getAverage(List<int[]> bucket)\n\t{\n\t\tint[] avg = {0, 0, 0};\n\t\tfor (int[] pixel : bucket)\n\t\t{\n\t\t\tfor (var i = 0; i < 3; i++)\n\t\t\t{\n\t\t\t\tavg[i] += pixel[i];\n\t\t\t}\n\t\t}\n\t\tfor (var i = 0; i < 3; i++)\n\t\t{\n\t\t\tavg[i] /= bucket.size();\n\t\t}\n\t\treturn avg;\n\t}\n\n\tprivate static int qualityToCompressionLevel(int quality)\n\t{\n\t\treturn switch (quality)\n\t\t{\n\t\t\tcase 3 -> 3;\n\t\t\tcase 2 -> 6;\n\t\t\tcase 1 -> 9;\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + quality);\n\t\t};\n\t}\n\n\t/**\n\t * Optimizes the given PNG image using the given compression level.\n\t *\n\t * @param in      the PNG image as a byte array\n\t * @param quality the quality (3, 2 and 1, 1 being best but most CPU intensive)\n\t * @param outputStream the output stream to write the image to\n\t * @throws IOException if an I/O error occurs\n\t */\n\tstatic void optimizePng(byte[] in, int quality, OutputStream outputStream) throws IOException\n\t{\n\t\tint compressionLevel = qualityToCompressionLevel(quality);\n\t\tvar pngImage = new PngImage(in);\n\t\tvar optimizer = new PngOptimizer();\n\t\tvar pngOut = optimizer.optimize(pngImage, true, compressionLevel);\n\t\tpngOut.writeDataOutputStream(outputStream);\n\t}\n\n\t/**\n\t * Writes the given image to PNG using the default compression level.\n\t * <p>\n\t * This compressor doesn't compress very well, and it's a good idea to run it through an optimizer.\n\t *\n\t * @param image the image to compress\n\t * @param outputStream the output stream to write the image to\n\t * @throws IOException if an I/O error occurs\n\t */\n\tstatic void writeBufferedImageToPng(BufferedImage image, OutputStream outputStream) throws IOException\n\t{\n\t\tvar pngWriter = ImageIO.getImageWritersByFormatName(\"PNG\").next();\n\t\tvar pngWriteParam = pngWriter.getDefaultWriteParam();\n\t\tpngWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);\n\t\tpngWriteParam.setCompressionQuality(0.5f); // This compressor is actually pretty bad and doesn't change much depending on the quality\n\n\t\tvar ios = ImageIO.createImageOutputStream(outputStream);\n\t\tpngWriter.setOutput(ios);\n\t\tvar outputImage = new IIOImage(image, null, null);\n\t\tpngWriter.write(null, outputImage, pngWriteParam);\n\t\tpngWriter.dispose();\n\t}\n}\n"
  },
  {
    "path": "common/src/main/javadoc/overview.html",
    "content": "<!--\n  ~ Copyright (c) 2024 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<body>\nThis is the common part of Xeres. It contains code shared between the server part and the UI part.\n</body>"
  },
  {
    "path": "common/src/main/resources/i18n/messages.properties",
    "content": "#\n# Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n\n# Common\n\nok=OK\ncancel=Cancel\nclose=Close\nsend=Send\ncreate=Create\nremove=Remove\ndownload=Download\nadd=Add\nopen=Open\ncopy-link=Copy Link Address\ncopy=Copy\nsave-as=Save as...\npaste-id=Paste own ID\nundo=Undo\nredo=Redo\ncut=Cut\npaste=Paste\ndelete=Delete\nselect-all=Select All\ndeselect-all=Deselect All\nview-fullscreen=View Fullscreen\ncopy-image=Copy Image\nsave-image-as=Save Image as...\nenabled=Enabled\nno-results=No results found\nskip=Skip\nname=Name\nhelp=Help\nsettings=Settings\nexit=Exit\nprofile=Profile\nsubscribed=Subscribed\nown=Own\ndescription=Description\nsubject=Subject\nhash=Hash\nsize=Size\ntrust=Trust\nunknown-lc=unknown\nlogo=Logo\nlatest=Latest\nupdate=Update\nedit=Edit\nbody=Body text (optional)\ntext=Text\nimage=Image\nlink=Link\nmark-read-unread=Mark as read/unread\nmark-unread=Mark as unread\nthumbnail=Thumbnail\nposts-at-remote-nodes=Posts at remote node\nlast-activity=Last activity\nstate=State\nip=IP\nport=Port\n\n# File Requesters\n\nfile-requester.profiles=Profile files\nfile-requester.xml=XML files\nfile-requester.png=PNG files\nfile-requester.sounds=Sound files\nfile-requester.select-sound-title=Choose a sound file\nfile-requester.images=Image files\nfile-requester.save-image-title=Select where to save your image\nfile-requester.error=Error with file {0}: {1}\nfile-requester.add-files=Select file(s) to add\n\n# Main\n\n## Menu\n\nmain.menu.add-peer=Add Peer...\nmain.menu.broadcast=Broadcast...\nmain.menu.shares=Configure shares\nmain.menu.statistics=Show statistics\n\nmain.menu.tools=Tools\nmain.menu.tools.import-from-rs=Import friends from Retroshare...\nmain.menu.tools.export=Export...\n\nmain.menu.help.about=About Xeres\nmain.menu.help.documentation=Documentation\nmain.menu.help.report-bug=Report bug ↗\nmain.menu.help.check-for-updates=Check for updates... ↗\n\nmain.friends-import-successful=Imported {0} locations successfully.\nmain.friends-import-errors=Imported {0} locations, but {1} had errors.\n\nmain.systray.peers={0,number,integer} peers connected\n\n## Splash\n\nsplash.status.database=Loading database\nsplash.status.network=Starting network\n\n## Content\n\nmain.home=Home\nmain.contacts=Contacts\nmain.chats=Chats\nmain.forums=Forums\nmain.files=Files\nmain.boards=Boards\nmain.channels=Channels\n\nmain.home.slogan=Where Friendship Meets Freedom\nmain.home.share-id=This is your Xeres ID. Share it with other people.\nmain.home.received-id=Did you receive an ID from a peer?\nmain.home.add-peer=Add Peer\nmain.home.add-peer.tip=Add a friend by pasting its ID\nmain.home.need-help=Need help?\nmain.home.online-help=Online Help ↗\nmain.home.online-help.tip=Show the online help (Ctrl+F1)\nmain.home.copy-id.tip=Copy Xeres ID to clipboard\nmain.home.qrcode.tip=Use the QR code to transfer your ID. Print it or take a picture with your phone then show it to a webcam.\n\nmain.select-avatar=Select Avatar Picture\nmain.export-profile=Select where to save your profile\nmain.import-friends=Select the Retroshare friends file\n\nmain.scanning=Scanning {0}...\nmain.hashing=Hashing {0}\nmain.scanning.tip=Share: {0}, file: {1}\n\n## Status\n\nmain.status.connections=Connections:\nmain.status.nat.unknown=Status is still unknown.\nmain.status.nat.firewalled=The client is not reachable from connections initiated from the Internet.\nmain.status.nat.upnp=UPNP is active and the client is fully reachable from the Internet.\nmain.status.dht.disabled=DHT is disabled.\nmain.status.dht.initializing=DHT is currently initializing. This can take a while.\nmain.status.dht.running=DHT is working properly, the client's IP address is advertised to its peers.\nmain.status.dht.stats=Number of peers: {0,number,integer}\\nReceived packets: {1,number,integer} ({2})\\nSent packets: {3,number,integer} ({4})\\nKey count: {5,number,integer}\\nItem count: {6,number,integer}\n\nmain.exit.confirm=Are you sure you want to exit Xeres?\n\n# Account creation\n\naccount.welcome=Welcome to Xeres\naccount.welcome.tip=You need to create a profile and a location.\\n\\nThe profile is yourself, you can use your name or nickname, while the location is the machine you're on.\\n\\nYou can have several locations like a desktop and a laptop that both use the same profile (you).\\n\\nUse the import option to import a profile that you already created before.\\n\\nEverything is always stored locally so don't forget to back up your data.\\n\\nPress the F1 key to read the built-in documentation, and remember that leaving your mouse pointer above a user interface element for a short while will describe what it is.\naccount.profile.prompt=Profile name\naccount.profile.tip=Use a nickname or real name. A profile can have several locations.\naccount.location=Location\naccount.location.prompt=Location name\naccount.location.tip=This is your Xeres' instance on this device. Use your device's nickname or model.\naccount.options=Options\naccount.generation.profile-keys=Generating profile keys...\naccount.generation.location-keys-and-certificate=Generating location keys and certificate...\naccount.generation.identity=Generating identity...\naccount.generation.profile-load=Select a Xeres profile file (xeres_backup.xml), a Retroshare keyring (retroshare_secret_keyring.gpg) or a Retroshare profile (*.asc)\naccount.generation.import=Import...\naccount.generation.import.tip=You can import three kinds of profiles:\\n\\nA profile exported from Xeres (xeres_backup.xml).\\n\\nA Retroshare keyring (retroshare_secret_keyring.gpg) or a profile exported from Retroshare (*.asc).\naccount.generation.import.progress=Importing profile...\naccount.generation.import.confirm.title=Retroshare Importer\naccount.generation.import.confirm.prompt=Enter the Retroshare password\naccount.generation.import.unknown=Unknown file format\n\n# Chat\n\n## Common\n\nchat.notification.typing={0} is typing\n\n## Room common\n\nchat.room.id=ID\nchat.room.topic=Topic\nchat.room.security=Security\nchat.room.users=Users\n\nchat.room.info=Topic: {0}\\nUsers: {1,number,integer}\\nSecurity: {2}\\nID: {3}\nchat.room.none=[none]\nchat.room.private=private\nchat.room.public=public\nchat.room.signed-only=signed IDs only\nchat.room.anonymous-allowed=anonymous IDs allowed\nchat.room.user-info=Name: {0}\\nID: {1}\nchat.room.user-menu=Information\nchat.room.clear-history=Do you really want to clear the history?\nchat.room.copy-selection=Copy selection\nchat.room.clear-chat-history=Clear chat history\n\n## Room create\n\nchat.room.create.window-title=Create Chat Room\nchat.room.create.name.prompt=Short and descriptive name of the room\nchat.room.create.name.tip=Name of the room. Use proper capitalization and spaces.\nchat.room.create.topic.prompt=What the room is about\nchat.room.create.topic.tip=The description of the room, what it is about.\nchat.room.create.visibility=Visibility\nchat.room.create.visibility.tip=Public rooms are visible by peers.\\nPrivate rooms aren't and work on invitation only.\nchat.room.create.security.checkbox=Signed identities only\nchat.room.create.security.tip=A room restricted to signed identities is more resistant to spam because anonymous identities cannot join.\nchat.room.create.tooltip=Create a new chat room\n\n## Room invite\n\nchat.room.invite.window-title=Invite peer to the current chat room\nchat.room.invite.button=Invite\nchat.room.invite.tip=Invite peers to the current chat room\nchat.room.invite.request={0} wants to invite you to {1} ({2})\n\nchat.room.join=Join\nchat.room.leave=Leave\n\nchat.room.not-found=Room not found. It's likely that the room is not available on any of your connected friends.\n\n# Forums\n\ngxs-group.tree.popular=Popular\ngxs-group.tree.other=Other\n\ngxs-group.tree.info=Name: {0}\\nID: {1}\\nRemote messages: {2}\\nRemote activity: {3}\n\ngxs-group.tree.subscribe=Subscribe\ngxs-group.tree.unsubscribe=Unsubscribe\n\nforum.new-message.window-title=New message\nforum.create.window-title=Create forum\nforum.create.name.prompt=Short and descriptive name of the forum\nforum.create.name.tip=Name of the forum. Use proper capitalization and spaces.\nforum.create.description.prompt=What the forum is about\nforum.create.description.tip=The description of the forum, what it is about.\n\nforum.editor.name=Forum\nforum.editor.name.prompt=The forum's name\nforum.editor.thread.description=The thread's subject\nforum.editor.cancel=The forum message has not been sent yet! Do you really want to discard this message?\n\nforum.view.create.tip=Create a new forum\nforum.view.header.author=Author\nforum.view.header.date=Date\n\nforum.view.new-message.tip=Create a new message\n\nforum.view.group.not-found=Forum not found. It's likely that it is not available on any of your connected friends.\nforum.view.message.not-found=Message not found. It's likely that the message is too old or the originator has a too low reputation.\n\nforum.view.from=From:\nforum.view.subject=Subject:\nforum.view.reply=Reply\n\nforum.view.history=This selector allows displaying previous message versions.\n\n# Boards\n\nboard.create.window-title=Create board\nboard.create.name.prompt=Short and descriptive name of the board\nboard.create.name.tip=Name of the board. Use proper capitalization and spaces.\nboard.create.description.prompt=What the board is about\nboard.create.description.tip=The description of the board, what it is about.\nboard.select-logo=Select Board Picture\nboard.select-image=Select Image to Post\n\nboard.view.create.tip=Create a new board\nboard.view.group.not-found=Board not found. It's likely that it is not available on any of your connected friends.\n\nboard.new-message.window-title=New board post\n\nboard.editor.name=Board\nboard.editor.name.prompt=The board's name\nboard.editor.thread.title=Title\nboard.editor.post.description=The post's title\n\nboard.editor.cancel=The board post has not been sent yet! Do you really want to discard this post?\n\nboard.posted-by=Posted by\nboard.on=on\n\n# Channels\n\nchannel.view.create.tip=Create a new channel\nchannel.create.window-title=Create channel\nchannel.create.name.prompt=Short and descriptive name of the channel\nchannel.create.name.tip=Name of the channel. Use proper capitalization and spaces.\nchannel.create.description.prompt=What the channel is about\nchannel.create.description.tip=The description of the channel, what it is about.\nchannel.select-image=Select Image for the channel post\n\nchannel.view.group.not-found=Channel not found. It's likely that it is not available on any of your connected friends.\n\nchannel.select-logo=Select Channel Picture\n\nchannel.new-message.window-title=New channel post\n\nchannel.editor.name=Channel\nchannel.editor.name.prompt=The channel's name\nchannel.editor.thread.title=Title\nchannel.editor.post.description=The post's title\n\nchannel.editor.cancel=The channel post has not been sent yet! Do you really want to discard this post?\nchannel.clipboard.error=Clipboard doesn't contain file links.\nchannel.files=Files\nchannel.post=Post\nchannel.drag-drop=Add files or drag and drop them here\nchannel.add-files=Add file(s)\nchannel.paste-links=Paste link(s)\nchannel.remove-files=Remove file(s)\n\n# Add RSID\n\nrs-id.add.window-title=Add Peer\nrs-id.add.textarea.prompt=Paste the ID of the peer\nrs-id.add.textarea.tip=The ID is a string of around a hundred of base64 characters. It encodes all information needed to connect to a peer.\nrs-id.add.details=Peer's details\nrs-id.add.name.tip=Name of the peer, make sure you know who it is.\nrs-id.add.profile=Profile ID\nrs-id.add.profile.tip=Unique ID to check if your peer's profile is the right one.\nrs-id.add.fingerprint=Fingerprint\nrs-id.add.fingerprint.tip=Cryptographic checksum to certify the authenticity of your peer's profile.\nrs-id.add.location=Location ID\nrs-id.add.location.tip=Location identifier. A profile can have several locations and each has a unique ID.\nrs-id.add.addresses=Addresses\nrs-id.add.addresses.tip=Addresses to connect to. They will all be tried in turn, but you can preselect the best one for a faster initial connection.\\nAddresses ending in .onion require using a Tor proxy.\\nAddresses ending in .i2p require using an I2P proxy.\nrs-id.add.trust.tip=The trust level you have on the peer.\\nUnknown: no opinion.\\nNever: none or minimal, met online recently.\\nMarginal: more or less trustable, acquaintance.\\nFull: very trustable, good friend.\nrs-id.add.invalid=Invalid ID\nrs-id.add.scan=Scan the QR Code using the camera.\n\n# Broadcast\n\nbroadcast.window-title=Broadcast\nbroadcast.send.explanation=Send a message to all currently connected peers.\nbroadcast.send.warning-header=Warning:\nbroadcast.send.warning=do not abuse this function. Only use it for emergencies or exceptional situations.\n\n# Messaging\n\nmessaging.prompt=Type a message\nmessaging.file-requester.send-picture=Select Picture to Send Inline\nmessaging.file-requester.send-file=Select File to Send\nmessaging.send-picture=Select an image to send inline\nmessaging.send-sticker=Send a sticker\nmessaging.send.file=Select a file to send\nmessaging.action.call=Make a direct call\nmessaging.action.send-inline=Send an image inline\nmessaging.action.send-file=Send a file\n\nmessaging.warning.title=Warning\nmessaging.warning.description=The user is currently offline and cannot receive messages.\n\nmessaging.tunneling=Trying to establish tunnel...\n\nmessaging.closing-tunnel.confirm=Closing this window will end the distant chat and drop all unsent messages. Are you sure?\n\n# Profiles\n\nprofiles.delete=Delete profile\n\n# About\n\nabout.window-title=About {0}\nabout.version=Version:\nabout.title=About\nabout.slogan=A Friend-to-Friend, decentralized and secure application for communication and sharing\nabout.authors=Authors\nabout.author-by=by\nabout.all-rights-reserved=All Rights Reserved\nabout.report-bugs=Report bugs or suggest improvements.\nabout.website=Website\nabout.wiki=Wiki\nabout.source-code=Source code\nabout.thanks=Thanks To\nabout.license=License\nabout.additional-licenses=Additional Licenses\nabout.release=Release\nabout.profiles=Profiles:\n\n# QR Code\n\nqr-code.window-title=QR Code\nqr-code.print=Print...\nqr-code.save-as-png=Save as PNG\nqr-code.download-client=Download client at https://xeres.io\nqr-code.camera.error=No camera has been detected\n\n# Camera\n\ncamera.window-title=Scan QR Code\n\n# Settings\n\n## Main\n\nsettings.general=General\nsettings.network=Network\nsettings.transfer=Transfer\nsettings.notifications=Notifications\nsettings.sound=Sound\nsettings.remote=Remote\nsettings.directory.no-remote=Cannot choose a directory in remote mode\n\n## General\n\nsettings.general.theme=Theme\nsettings.general.system=System\nsettings.general.startup=Launch on system startup\nsettings.general.startup.tip=Run automatically when the system starts, minimized in the tray.\nsettings.general.startup.not-available=Not available. Either the OS is not supported or you're running in portable mode.\nsettings.general.update-check=Automatically check for updates\nsettings.general.update-check.tip=Automatically checks GitHub once per day to see if there's a new release.\n\n## Network\n\nsettings.network.hidden-services=Hidden Services\nsettings.network.tor-proxy=Tor Socks Proxy\nsettings.network.tor-proxy.prompt=Tor server\nsettings.network.tor-proxy.tip=The Tor SOCKS v5 IP address or hostname, usually 127.0.0.1 if running on the same host.\nsettings.network.tor-port.tip=The Tor SOCKS v5 port, usually 9050.\nsettings.network.i2p-proxy=I2P Socks Proxy\nsettings.network.i2p-proxy.prompt=I2P server\nsettings.network.i2p-proxy.tip=The I2P SOCKS v5 IP address or hostname, usually 127.0.0.1 if running on the same host.\nsettings.network.i2p-port.tip=The I2P SOCKS v5 port, usually 4447.\nsettings.network.use-upnp=Use UPNP\nsettings.network.use-upnp.tip=UPNP (Universal Plug and Play) allows automatically setting the correct incoming ports in your router. This improves the connection reliability from your peers.\nsettings.network.external-ip-and-port=External IP and Port\nsettings.network.external-ip-and-port.tip=The external IP address and port of your location. This is how your connection appears on the Internet.\nsettings.network.use-broadcast-discovery=Enable Broadcast Discovery\nsettings.network.use-broadcast-discovery.tip=Broadcast Discovery allows telling your IP and port to other locations on your LAN. This improves the connection reliability from eventual peers on your LAN.\nsettings.network.internal-ip-and-port=Internal IP and Port\nsettings.network.internal-ip-and-port.tip=The internal IP address and port of your location. This is how your connection appears on your LAN (Local Area Network).\nsettings.network.use-dht=Enable DHT\nsettings.network.use-dht.tip=The DHT (Distributed Hash Table) allows peers to find each other's IP address when it changes. This improves the connectivity when roaming.\n\n## Remote\n\nsettings.remote.title=Remote Access\nsettings.remote.username=Username\nsettings.remote.password=Password\nsettings.remote.note=Setting an empty password disables authentication.\nsettings.remote.enabled.tip=Enable remote access. This instance can then be accessed either from another Xeres instance or from the Android client.\nsettings.remote.upnp-set=Set with UPNP\nsettings.remote.upnp-set.tip=Set the remote port with UPNP, making it accessible from the WAN.\nsettings.remote.restart=You need to restart Xeres in order for the remote access changes to be effective. Exit now?\nsettings.remote.view-api=View API\n\n## Transfer\n\nsettings.transfer.select-incoming=Select Incoming Directory\nsettings.transfer.incoming=Incoming directory\n\n## Notifications\n\nsettings.notifications.show-connections=Show connections\nsettings.notifications.show-connections.tip=Shows when a connection with a friend is made.\nsettings.notifications.show-broadcasts=Show broadcasts\nsettings.notifications.show-broadcasts.tip=Shows message broadcasts sent by friends.\nsettings.notifications.show-discovery=Show discovery\nsettings.notifications.show-discovery.tip=Shows when a client who has Broadcast Discovery enabled appears on the LAN.\n\n## Sound\n\nsettings.sound.message=Message received\nsettings.sound.message.tip=Plays a sound when a private message is received and the window is inactive.\nsettings.sound.highlight=Highlight\nsettings.sound.highlight.tip=Plays a sound when someone addresses you in a chat room.\nsettings.sound.friend=Friend connected\nsettings.sound.friend.tip=Plays a sound when a friend connects to you.\nsettings.sound.download=Download complete\nsettings.sound.download.tip=Plays a sound when a download is complete.\nsettings.sound.ringing=Call\nsettings.sound.ringing.tip=Plays a sound when receiving or doing a call.\n\n# Share\n\nshare.window-title=Shares\nshare.select-directory=Select directory to share\nshare.remove=Remove share\nshare.error.empty-name=Share name cannot be empty. Set a unique name.\nshare.error.empty-path=Share path cannot be empty. Set a share path.\nshare.error.not-unique=Share name already exists. Each share name has to be unique.\n\nshare.list.directory=Shared directory\nshare.list.visible-name=Visible name\nshare.list.searchable=Searchable\nshare.list.browsable=Browsable\n\nshare.create=Create a new share\nshare.apply=Apply and close\n\n# Tray\n\ntray.open=Open {0}\ntray.peers=Peers\ntray.status=Status\n\n# EditorView\n\neditor.hyperlink.enter=Enter URL\neditor.action.undo=Undo (Ctrl+Z)\neditor.action.redo=Redo (Ctrl+Shift+Z)\neditor.action.bold=Bold (Ctrl+B)\neditor.action.italic=Italic (Ctrl+I)\neditor.action.hyperlink=Link (Ctrl+L)\neditor.action.quote=Quote (Ctrl+Q)\neditor.action.code=Code (Ctrl+K)\neditor.action.unordered-list=Unordered list (Ctrl+U)\neditor.action.ordered-list=Ordered list (Ctrl+Shift+U)\neditor.action.header=Header (Ctrl+1)\neditor.action.preview=Preview the message (F12)\n\n# Search / Download / Uploads\n\nsearch.main.search=Search\nsearch.main.downloads=Downloads\nsearch.main.uploads=Uploads\nsearch.main.trends=Trends\n\nsearch.input.prompt=Enter search terms\nsearch.input.search.tip=Typing several search terms will search files that contain all of them. Use \\\" around the search terms for an exact match.\nsearch.searching=Searching...\n\ntrends.none=No trends yet\ntrends.list.terms=Terms\ntrends.list.from=From\ntrends.list.time=Time\n\ndownload-view.list.none=No downloads\ndownload-view.list.state=State\ndownload-view.list.progress=Progress\ndownload-view.list.total-size=Total Size\n\ndownload-view.show-in-folder=Show in folder\n\ndownload-view.open-error=Failed to open:\ndownload-view.show-error=Failed to show in explorer:\n\ndownload-add.window-title=Add Download\ndownload-add.bytes={0,number,integer} bytes\n\nupload-view.none=No files being uploaded\n\nfile-result.column.type=Type\n\n# StatisticsTurtle\n\nstatistics.window-title=Statistics\n\nstatistics.elapsed-time=Elapsed time (seconds)\n\nstatistics.turtle.data-in=Data In\nstatistics.turtle.data-in.tip=The content data received (downloads)\nstatistics.turtle.data-out=Data Out\nstatistics.turtle.data-out.tip=The content data sent (uploads)\nstatistics.turtle.data-forward=Data Forward\nstatistics.turtle.data-forward.tip=The content data forwarded to other peers\nstatistics.turtle.tunnel-in=Tunnel Reqs In\nstatistics.turtle.tunnel-in.tip=Incoming tunnel requests\nstatistics.turtle.tunnel-out=Tunnel Reqs Out\nstatistics.turtle.tunnel-out.tip=Tunnel requests forwarded and own requests\nstatistics.turtle.search-in=Search Reqs In\nstatistics.turtle.search-in.tip=Incoming search requests\nstatistics.turtle.search-out=Search Reqs Out\nstatistics.turtle.search-out.tip=Search requests forwarded and our own requests\nstatistics.turtle.bandwidth=Bandwidth\nstatistics.turtle.speed=Speed (KB/s)\nstatistics.turtle.tip=The chart shows the turtle router statistics. It consists of:\\nSearch requests: file searches (title, size, etc...).\\nTunnel requests: tunnel setups between remote peers to prepare for a file transfer.\\nData requests: data that flows within tunnels.\\nMost requests and data that is not destined to our own node is forwarded to other peers, within a given probability that varies given the distance.\n\nstatistics.rtt.rtt=Round Trip Time\nstatistics.rtt.time=RTT (milliseconds)\nstatistics.rtt.tip=The RTT (Round Trip Time), is the amount of time taken for a message to be sent to a destination and for a reply to be sent back to the sender. It gives an idea of the network latency between peers.\\nExpect problems if the RTT is too high (more than a few seconds).\n\nstatistics.data-counter.title=Data Usage\nstatistics.data-counter.data=Data (KB)\nstatistics.data-counter.tip=This chart shows the amount of data coming in and going out to peers.\nstatistics.data-counter.peers=Peers\n\nstatistics.turtle=Turtle\nstatistics.rtt=RTT\nstatistics.data-usage=Data usage\n\n# ContactView\n\ncontact-view.profile-delete.confirm=This will remove and disconnect your from profile {0}. Do you really want to?\ncontact-view.avatar-delete.confirm=Do you really want to remove your avatar image?\ncontact-view.location.last-connected.now=Now\ncontact-view.location.last-connected.never=Never\ncontact-view.information.linked-to-profile=Identity linked to profile\ncontact-view.information.profile=Profile\ncontact-view.information.identity=Identity\ncontact-view.information.type=Type\ncontact-view.information.created=Created\ncontact-view.information.updated=Updated\ncontact-view.information.created-unknown=unknown\ncontact-view.information.key-information-with-length=Version: {0}\\nAlgorithm: {1}\\nLength: {2} bits\\nSignature hash: {3}\ncontact-view.information.key-information=Version: {0}\\nAlgorithm: {1}\\nSignature hash: {2}\ncontact-view.open.identity-not-found=Identity not found\ncontact-view.open.profile-not-found=Profile not found\ncontact-view.information.location.id=Location ID:\ncontact-view.information.location.version=Version:\ncontact-view.search.prompt=Search people\ncontact-view.search.show-all=Show all contacts\ncontact-view.search.no-contacts=No contacts\ncontact-view.badge.own=Own\ncontact-view.badge.own.tip=This is yourself.\ncontact-view.badge.partial=Partial\ncontact-view.badge.partial.tip=A partial contact is not backed by a full profile yet. It needs to be connected to at least once, then it will be checked and, if successful, promoted to a full profile.\ncontact-view.badge.accepted=Accepted\ncontact-view.badge.accepted.tip=This contact is accepted for incoming connections, and outgoing connections to it are attempted as well.\ncontact-view.badge.not-validated=Not validated yet\ncontact-view.badge.not-validated.tip=The contact has not been validated yet. Its profile signature will be verified shortly and, if successful, will be marked as valid. If unsuccessful, it will be deleted (but might be transferred again, if so, try to inform its owner about the problem).\ncontact-view.action.chat=Chat\ncontact-view.action.distant-chat=Distant chat\ncontact-view.action.connect=Attempt to connect\ncontact-view.information.locations=Locations\ncontact-view.column.last-connected=Last Connected\ncontact-view.chat.start=Start direct chat\ncontact-view.distant-chat.start=Start distant chat\n\n# ImageSelectorView\n\nimage-selector-view.change-image=Change image...\nimage-selector-view.change-image-short=Change\nimage-selector-view.add-image=Add image...\n\n# VoIP\n\nvoip.window-title=Call\nvoip.action.message=Message\nvoip.action.message.tip=Send a direct chat message to the user\nvoip.action.recall=Call again\nvoip.action.recall.tip=Call the user back\nvoip.action.close.tip=Close the window\nvoip.action.answer=Answer\nvoip.action.reject=Reject\nvoip.action.hangup=Hang up\nvoip.action.window-quit=Are you sure you want to abort the call?\nvoip.status.incoming=Incoming call...\nvoip.status.calling=Calling...\nvoip.status.ongoing=In call\nvoip.status.ended=Call ended\n\n# Update\n\nupdate.latest-already=You already have the latest version.\nupdate.new-version=There''s a new version available ({0}). Download, verify and install?\nupdate.new-version-auto=There''s a new version available ({0}).\nupdate.download-failure=Couldn''t download url and/or signing url\nupdate.download-file=Downloading file...\nupdate.download.title=Xeres Updater\nupdate.download.verifying=Verifying file...\nupdate.download.install=Install\nupdate.download.install-ready=Ready to install!\nupdate.download-verification-failed=Verification failed!\n\n# Stickers\n\nstickers.instructions=Add your stickers into {0}\\n\\nOne directory per sticker collection, each containing PNGs or JPEGs.\n\n# ChatCommands\n\nchat-command.code=Send the text as a block of code\nchat-command.coin=Flip a coin\nchat-command.me=Send an action message in the third person\nchat-command.pre=Send the text as preformatted\nchat-command.quote=Send the text as a quote\nchat-command.random=Send a random number from 1 to 10\nchat-command-send=Send {0}\n\n# Misc\n\nuri.malicious-link=Warning! This is a malicious link, clicking will get you to: {0}\nuri.unsafe-link=Warning! This link might be unsafe, clicking will get you to: {0}\nuri.malicious-link.confirm=Warning! This is a malicious link, it will get you to {0}. Do you really want to?\nuri.unsafe-link.confirm=Warning! This link might be unsafe, it will get you to {0}. Do you know what it is and do you trust it?\n\ncontent-image.exit=Press ESC or click to exit\n\nwebsocket.disconnected=WebSocket connection lost. Chat unavailable. Reconnect?\n\n# TrustConverter\n\ntrust-converter.nobody=Nobody\ntrust-converter.everybody=Everybody\ntrust-converter.marginal=Marginal trustees\ntrust-converter.full=Full trustees\ntrust-converter.ultimate=Only myself\n\n# Byte units\n\nbyte-unit.invalid=invalid\nbyte-unit.bytes=bytes\nbyte-unit.kb=KB\nbyte-unit.mb=MB\nbyte-unit.gb=GB\nbyte-unit.tb=TB\nbyte-unit.pb=PB\nbyte-unit.eb=EB\n\n# Help\n\nhelp.back=Go back in the history\nhelp.forward=Go forward in the history\nhelp.home=Go to the home section\n\n# Enums (beware of the key naming which must be the same as the class!)\n\n## Trust\n\n# suppress inspection \"UnusedProperty\"\nenum.trust.unknown=Unknown\n# suppress inspection \"UnusedProperty\"\nenum.trust.never=Never\n# suppress inspection \"UnusedProperty\"\nenum.trust.marginal=Marginal\n# suppress inspection \"UnusedProperty\"\nenum.trust.full=Full\n# suppress inspection \"UnusedProperty\"\nenum.trust.ultimate=Ultimate\n\n## Availability\n\n# suppress inspection \"UnusedProperty\"\nenum.availability.available=Available\n# suppress inspection \"UnusedProperty\"\nenum.availability.busy=Busy\n# suppress inspection \"UnusedProperty\"\nenum.availability.away=Away\n# suppress inspection \"UnusedProperty\"\nenum.availability.offline=Offline\n\n## RoomType\n\n# suppress inspection \"UnusedProperty\"\nenum.room-type.private=Private\n# suppress inspection \"UnusedProperty\"\nenum.room-type.public=Public\n\n## FileType\n\n# suppress inspection \"UnusedProperty\"\nenum.file-type.any=Any\n# suppress inspection \"UnusedProperty\"\nenum.file-type.audio=Audio\n# suppress inspection \"UnusedProperty\"\nenum.file-type.archive=Archive\n# suppress inspection \"UnusedProperty\"\nenum.file-type.document=Document\n# suppress inspection \"UnusedProperty\"\nenum.file-type.picture=Picture\n# suppress inspection \"UnusedProperty\"\nenum.file-type.program=Program\n# suppress inspection \"UnusedProperty\"\nenum.file-type.video=Video\n# suppress inspection \"UnusedProperty\"\nenum.file-type.subtitles=Subtitle\n# suppress inspection \"UnusedProperty\"\nenum.file-type.collection=Collection\n# suppress inspection \"UnusedProperty\"\nenum.file-type.directory=Directory\n\n## FileProgressDisplay State\n\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.searching=Searching\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.transferring=Transferring\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.removing=Removing\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.done=Done\n\n## FileAttachment State\n\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.hashing=Hashing\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.done=Done\n\n## Country\n\n# suppress inspection \"UnusedProperty\"\nenum.country.af=Afghanistan\n# suppress inspection \"UnusedProperty\"\nenum.country.al=Albania\n# suppress inspection \"UnusedProperty\"\nenum.country.dz=Algeria\n# suppress inspection \"UnusedProperty\"\nenum.country.as=American Samoa\n# suppress inspection \"UnusedProperty\"\nenum.country.ad=Andorra\n# suppress inspection \"UnusedProperty\"\nenum.country.ao=Angola\n# suppress inspection \"UnusedProperty\"\nenum.country.ai=Anguilla\n# suppress inspection \"UnusedProperty\"\nenum.country.aq=Antarctica\n# suppress inspection \"UnusedProperty\"\nenum.country.ag=Antigua and Barbuda\n# suppress inspection \"UnusedProperty\"\nenum.country.ar=Argentina\n# suppress inspection \"UnusedProperty\"\nenum.country.am=Armenia\n# suppress inspection \"UnusedProperty\"\nenum.country.aw=Aruba\n# suppress inspection \"UnusedProperty\"\nenum.country.au=Australia\n# suppress inspection \"UnusedProperty\"\nenum.country.at=Austria\n# suppress inspection \"UnusedProperty\"\nenum.country.az=Azerbaijan\n# suppress inspection \"UnusedProperty\"\nenum.country.ba=Bosnia and Herzegovina\n# suppress inspection \"UnusedProperty\"\nenum.country.bs=Bahamas\n# suppress inspection \"UnusedProperty\"\nenum.country.bh=Bahrain\n# suppress inspection \"UnusedProperty\"\nenum.country.bd=Bangladesh\n# suppress inspection \"UnusedProperty\"\nenum.country.bb=Barbados\n# suppress inspection \"UnusedProperty\"\nenum.country.by=Belarus\n# suppress inspection \"UnusedProperty\"\nenum.country.be=Belgium\n# suppress inspection \"UnusedProperty\"\nenum.country.bz=Belize\n# suppress inspection \"UnusedProperty\"\nenum.country.bj=Benin\n# suppress inspection \"UnusedProperty\"\nenum.country.bm=Bermuda\n# suppress inspection \"UnusedProperty\"\nenum.country.bt=Bhutan\n# suppress inspection \"UnusedProperty\"\nenum.country.bo=Bolivia\n# suppress inspection \"UnusedProperty\"\nenum.country.bw=Botswana\n# suppress inspection \"UnusedProperty\"\nenum.country.bv=Bouvet Island\n# suppress inspection \"UnusedProperty\"\nenum.country.br=Brazil\n# suppress inspection \"UnusedProperty\"\nenum.country.io=British Indian Ocean Territory\n# suppress inspection \"UnusedProperty\"\nenum.country.bn=Brunei\n# suppress inspection \"UnusedProperty\"\nenum.country.bg=Bulgaria\n# suppress inspection \"UnusedProperty\"\nenum.country.bf=Burkina Faso\n# suppress inspection \"UnusedProperty\"\nenum.country.bi=Burundi\n# suppress inspection \"UnusedProperty\"\nenum.country.kh=Cambodia\n# suppress inspection \"UnusedProperty\"\nenum.country.cm=Cameroon\n# suppress inspection \"UnusedProperty\"\nenum.country.ca=Canada\n# suppress inspection \"UnusedProperty\"\nenum.country.cv=Cape Verde\n# suppress inspection \"UnusedProperty\"\nenum.country.ky=Cayman Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.cf=Central African Republic\n# suppress inspection \"UnusedProperty\"\nenum.country.td=Chad\n# suppress inspection \"UnusedProperty\"\nenum.country.cl=Chile\n# suppress inspection \"UnusedProperty\"\nenum.country.cn=China\n# suppress inspection \"UnusedProperty\"\nenum.country.cx=Christmas Island\n# suppress inspection \"UnusedProperty\"\nenum.country.cc=Cocos (Keeling) Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.co=Colombia\n# suppress inspection \"UnusedProperty\"\nenum.country.km=Comoros\n# suppress inspection \"UnusedProperty\"\nenum.country.cg=Congo\n# suppress inspection \"UnusedProperty\"\nenum.country.cd=Democratic Republic of the Congo\n# suppress inspection \"UnusedProperty\"\nenum.country.ck=Cook Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.cr=Costa Rica\n# suppress inspection \"UnusedProperty\"\nenum.country.ci=Ivory Coast\n# suppress inspection \"UnusedProperty\"\nenum.country.hr=Croatia\n# suppress inspection \"UnusedProperty\"\nenum.country.cu=Cuba\n# suppress inspection \"UnusedProperty\"\nenum.country.cy=Cyprus\n# suppress inspection \"UnusedProperty\"\nenum.country.cz=Czech Republic\n# suppress inspection \"UnusedProperty\"\nenum.country.dk=Denmark\n# suppress inspection \"UnusedProperty\"\nenum.country.dj=Djibouti\n# suppress inspection \"UnusedProperty\"\nenum.country.dm=Dominica\n# suppress inspection \"UnusedProperty\"\nenum.country.do=Dominican Republic\n# suppress inspection \"UnusedProperty\"\nenum.country.ec=Ecuador\n# suppress inspection \"UnusedProperty\"\nenum.country.eg=Egypt\n# suppress inspection \"UnusedProperty\"\nenum.country.sv=El Salvador\n# suppress inspection \"UnusedProperty\"\nenum.country.gq=Equatorial Guinea\n# suppress inspection \"UnusedProperty\"\nenum.country.er=Eritrea\n# suppress inspection \"UnusedProperty\"\nenum.country.ee=Estonia\n# suppress inspection \"UnusedProperty\"\nenum.country.et=Ethiopia\n# suppress inspection \"UnusedProperty\"\nenum.country.fk=Falkland Islands (Malvinas)\n# suppress inspection \"UnusedProperty\"\nenum.country.fo=Faroe Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.fj=Fiji\n# suppress inspection \"UnusedProperty\"\nenum.country.fi=Finland\n# suppress inspection \"UnusedProperty\"\nenum.country.fr=France\n# suppress inspection \"UnusedProperty\"\nenum.country.gf=French Guiana\n# suppress inspection \"UnusedProperty\"\nenum.country.pf=French Polynesia\n# suppress inspection \"UnusedProperty\"\nenum.country.tf=French Southern Territories\n# suppress inspection \"UnusedProperty\"\nenum.country.ga=Gabon\n# suppress inspection \"UnusedProperty\"\nenum.country.gm=Gambia\n# suppress inspection \"UnusedProperty\"\nenum.country.ge=Georgia\n# suppress inspection \"UnusedProperty\"\nenum.country.de=Germany\n# suppress inspection \"UnusedProperty\"\nenum.country.gh=Ghana\n# suppress inspection \"UnusedProperty\"\nenum.country.gi=Gibraltar\n# suppress inspection \"UnusedProperty\"\nenum.country.gr=Greece\n# suppress inspection \"UnusedProperty\"\nenum.country.gl=Greenland\n# suppress inspection \"UnusedProperty\"\nenum.country.gd=Grenada\n# suppress inspection \"UnusedProperty\"\nenum.country.gp=Guadeloupe\n# suppress inspection \"UnusedProperty\"\nenum.country.gu=Guam\n# suppress inspection \"UnusedProperty\"\nenum.country.gt=Guatemala\n# suppress inspection \"UnusedProperty\"\nenum.country.gg=Guernsey\n# suppress inspection \"UnusedProperty\"\nenum.country.gn=Guinea\n# suppress inspection \"UnusedProperty\"\nenum.country.gw=Guinea-Bissau\n# suppress inspection \"UnusedProperty\"\nenum.country.gy=Guyana\n# suppress inspection \"UnusedProperty\"\nenum.country.ht=Haiti\n# suppress inspection \"UnusedProperty\"\nenum.country.hm=Heard Island and McDonald Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.va=Holy See (Vatican City State)\n# suppress inspection \"UnusedProperty\"\nenum.country.hn=Honduras\n# suppress inspection \"UnusedProperty\"\nenum.country.hk=Hong Kong\n# suppress inspection \"UnusedProperty\"\nenum.country.hu=Hungary\n# suppress inspection \"UnusedProperty\"\nenum.country.is=Iceland\n# suppress inspection \"UnusedProperty\"\nenum.country.in=India\n# suppress inspection \"UnusedProperty\"\nenum.country.id=Indonesia\n# suppress inspection \"UnusedProperty\"\nenum.country.ir=Iran\n# suppress inspection \"UnusedProperty\"\nenum.country.iq=Iraq\n# suppress inspection \"UnusedProperty\"\nenum.country.ie=Ireland\n# suppress inspection \"UnusedProperty\"\nenum.country.im=Isle of Man\n# suppress inspection \"UnusedProperty\"\nenum.country.il=Israel\n# suppress inspection \"UnusedProperty\"\nenum.country.it=Italy\n# suppress inspection \"UnusedProperty\"\nenum.country.jm=Jamaica\n# suppress inspection \"UnusedProperty\"\nenum.country.jp=Japan\n# suppress inspection \"UnusedProperty\"\nenum.country.je=Jersey\n# suppress inspection \"UnusedProperty\"\nenum.country.jo=Jordan\n# suppress inspection \"UnusedProperty\"\nenum.country.kz=Kazakhstan\n# suppress inspection \"UnusedProperty\"\nenum.country.ke=Kenya\n# suppress inspection \"UnusedProperty\"\nenum.country.ki=Kiribati\n# suppress inspection \"UnusedProperty\"\nenum.country.kp=North Korea\n# suppress inspection \"UnusedProperty\"\nenum.country.kr=South Korea\n# suppress inspection \"UnusedProperty\"\nenum.country.kw=Kuwait\n# suppress inspection \"UnusedProperty\"\nenum.country.kg=Kyrgyzstan\n# suppress inspection \"UnusedProperty\"\nenum.country.la=Lao People's Democratic Republic\n# suppress inspection \"UnusedProperty\"\nenum.country.lv=Latvia\n# suppress inspection \"UnusedProperty\"\nenum.country.lb=Lebanon\n# suppress inspection \"UnusedProperty\"\nenum.country.ls=Lesotho\n# suppress inspection \"UnusedProperty\"\nenum.country.lr=Liberia\n# suppress inspection \"UnusedProperty\"\nenum.country.ly=Libya\n# suppress inspection \"UnusedProperty\"\nenum.country.li=Liechtenstein\n# suppress inspection \"UnusedProperty\"\nenum.country.lt=Lithuania\n# suppress inspection \"UnusedProperty\"\nenum.country.lu=Luxembourg\n# suppress inspection \"UnusedProperty\"\nenum.country.mo=Macao\n# suppress inspection \"UnusedProperty\"\nenum.country.mk=Macedonia\n# suppress inspection \"UnusedProperty\"\nenum.country.mg=Madagascar\n# suppress inspection \"UnusedProperty\"\nenum.country.mw=Malawi\n# suppress inspection \"UnusedProperty\"\nenum.country.my=Malaysia\n# suppress inspection \"UnusedProperty\"\nenum.country.mv=Maldives\n# suppress inspection \"UnusedProperty\"\nenum.country.ml=Mali\n# suppress inspection \"UnusedProperty\"\nenum.country.mt=Malta\n# suppress inspection \"UnusedProperty\"\nenum.country.mh=Marshall Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.mq=Martinique\n# suppress inspection \"UnusedProperty\"\nenum.country.mr=Mauritania\n# suppress inspection \"UnusedProperty\"\nenum.country.mu=Mauritius\n# suppress inspection \"UnusedProperty\"\nenum.country.yt=Mayotte\n# suppress inspection \"UnusedProperty\"\nenum.country.mx=Mexico\n# suppress inspection \"UnusedProperty\"\nenum.country.fm=Micronesia\n# suppress inspection \"UnusedProperty\"\nenum.country.md=Moldova\n# suppress inspection \"UnusedProperty\"\nenum.country.mc=Monaco\n# suppress inspection \"UnusedProperty\"\nenum.country.mn=Mongolia\n# suppress inspection \"UnusedProperty\"\nenum.country.me=Montenegro\n# suppress inspection \"UnusedProperty\"\nenum.country.ms=Montserrat\n# suppress inspection \"UnusedProperty\"\nenum.country.ma=Morocco\n# suppress inspection \"UnusedProperty\"\nenum.country.mz=Mozambique\n# suppress inspection \"UnusedProperty\"\nenum.country.mm=Myanmar\n# suppress inspection \"UnusedProperty\"\nenum.country.na=Namibia\n# suppress inspection \"UnusedProperty\"\nenum.country.nr=Nauru\n# suppress inspection \"UnusedProperty\"\nenum.country.np=Nepal\n# suppress inspection \"UnusedProperty\"\nenum.country.nl=Netherlands\n# suppress inspection \"UnusedProperty\"\nenum.country.an=Netherlands Antilles\n# suppress inspection \"UnusedProperty\"\nenum.country.nc=New Caledonia\n# suppress inspection \"UnusedProperty\"\nenum.country.nz=New Zealand\n# suppress inspection \"UnusedProperty\"\nenum.country.ni=Nicaragua\n# suppress inspection \"UnusedProperty\"\nenum.country.ne=Niger\n# suppress inspection \"UnusedProperty\"\nenum.country.ng=Nigeria\n# suppress inspection \"UnusedProperty\"\nenum.country.nu=Niue\n# suppress inspection \"UnusedProperty\"\nenum.country.nf=Norfolk Island\n# suppress inspection \"UnusedProperty\"\nenum.country.mp=Northern Mariana Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.no=Norway\n# suppress inspection \"UnusedProperty\"\nenum.country.om=Oman\n# suppress inspection \"UnusedProperty\"\nenum.country.pk=Pakistan\n# suppress inspection \"UnusedProperty\"\nenum.country.pw=Palau\n# suppress inspection \"UnusedProperty\"\nenum.country.ps=Palestine\n# suppress inspection \"UnusedProperty\"\nenum.country.pa=Panama\n# suppress inspection \"UnusedProperty\"\nenum.country.pg=Papua New Guinea\n# suppress inspection \"UnusedProperty\"\nenum.country.py=Paraguay\n# suppress inspection \"UnusedProperty\"\nenum.country.pe=Peru\n# suppress inspection \"UnusedProperty\"\nenum.country.ph=Philippines\n# suppress inspection \"UnusedProperty\"\nenum.country.pn=Pitcairn\n# suppress inspection \"UnusedProperty\"\nenum.country.pl=Poland\n# suppress inspection \"UnusedProperty\"\nenum.country.pt=Portugal\n# suppress inspection \"UnusedProperty\"\nenum.country.pr=Puerto Rico\n# suppress inspection \"UnusedProperty\"\nenum.country.qa=Qatar\n# suppress inspection \"UnusedProperty\"\nenum.country.re=Reunion\n# suppress inspection \"UnusedProperty\"\nenum.country.ro=Romania\n# suppress inspection \"UnusedProperty\"\nenum.country.ru=Russia\n# suppress inspection \"UnusedProperty\"\nenum.country.rw=Rwanda\n# suppress inspection \"UnusedProperty\"\nenum.country.sh=Saint Helena, Ascension and Tristan da Cunha\n# suppress inspection \"UnusedProperty\"\nenum.country.kn=Saint Kitts and Nevis\n# suppress inspection \"UnusedProperty\"\nenum.country.lc=Saint Lucia\n# suppress inspection \"UnusedProperty\"\nenum.country.pm=Saint Pierre and Miquelon\n# suppress inspection \"UnusedProperty\"\nenum.country.vc=Saint Vincent and the Grenadines\n# suppress inspection \"UnusedProperty\"\nenum.country.ws=Samoa\n# suppress inspection \"UnusedProperty\"\nenum.country.sm=San Marino\n# suppress inspection \"UnusedProperty\"\nenum.country.st=Sao Tome and Principe\n# suppress inspection \"UnusedProperty\"\nenum.country.sa=Saudi Arabia\n# suppress inspection \"UnusedProperty\"\nenum.country.sn=Senegal\n# suppress inspection \"UnusedProperty\"\nenum.country.rs=Serbia\n# suppress inspection \"UnusedProperty\"\nenum.country.sc=Seychelles\n# suppress inspection \"UnusedProperty\"\nenum.country.sl=Sierra Leone\n# suppress inspection \"UnusedProperty\"\nenum.country.sg=Singapore\n# suppress inspection \"UnusedProperty\"\nenum.country.sk=Slovakia\n# suppress inspection \"UnusedProperty\"\nenum.country.si=Slovenia\n# suppress inspection \"UnusedProperty\"\nenum.country.sb=Solomon Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.so=Somalia\n# suppress inspection \"UnusedProperty\"\nenum.country.za=South Africa\n# suppress inspection \"UnusedProperty\"\nenum.country.gs=South Georgia and the South Sandwich Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.ss=South Sudan\n# suppress inspection \"UnusedProperty\"\nenum.country.es=Spain\n# suppress inspection \"UnusedProperty\"\nenum.country.lk=Sri Lanka\n# suppress inspection \"UnusedProperty\"\nenum.country.sd=Sudan\n# suppress inspection \"UnusedProperty\"\nenum.country.sr=Suriname\n# suppress inspection \"UnusedProperty\"\nenum.country.sj=Svalbard and Jan Mayen\n# suppress inspection \"UnusedProperty\"\nenum.country.sz=Swaziland\n# suppress inspection \"UnusedProperty\"\nenum.country.se=Sweden\n# suppress inspection \"UnusedProperty\"\nenum.country.ch=Switzerland\n# suppress inspection \"UnusedProperty\"\nenum.country.sy=Syria\n# suppress inspection \"UnusedProperty\"\nenum.country.tw=Taiwan\n# suppress inspection \"UnusedProperty\"\nenum.country.tj=Tajikistan\n# suppress inspection \"UnusedProperty\"\nenum.country.tz=Tanzania\n# suppress inspection \"UnusedProperty\"\nenum.country.th=Thailand\n# suppress inspection \"UnusedProperty\"\nenum.country.tl=Timor-Leste\n# suppress inspection \"UnusedProperty\"\nenum.country.tg=Togo\n# suppress inspection \"UnusedProperty\"\nenum.country.tk=Tokelau\n# suppress inspection \"UnusedProperty\"\nenum.country.to=Tonga\n# suppress inspection \"UnusedProperty\"\nenum.country.tt=Trinidad and Tobago\n# suppress inspection \"UnusedProperty\"\nenum.country.tn=Tunisia\n# suppress inspection \"UnusedProperty\"\nenum.country.tr=Turkey\n# suppress inspection \"UnusedProperty\"\nenum.country.tm=Turkmenistan\n# suppress inspection \"UnusedProperty\"\nenum.country.tc=Turks and Caicos Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.tv=Tuvalu\n# suppress inspection \"UnusedProperty\"\nenum.country.ug=Uganda\n# suppress inspection \"UnusedProperty\"\nenum.country.ua=Ukraine\n# suppress inspection \"UnusedProperty\"\nenum.country.ae=United Arab Emirates\n# suppress inspection \"UnusedProperty\"\nenum.country.gb=United Kingdom\n# suppress inspection \"UnusedProperty\"\nenum.country.us=United States\n# suppress inspection \"UnusedProperty\"\nenum.country.um=United States Minor Outlying Islands\n# suppress inspection \"UnusedProperty\"\nenum.country.uy=Uruguay\n# suppress inspection \"UnusedProperty\"\nenum.country.uz=Uzbekistan\n# suppress inspection \"UnusedProperty\"\nenum.country.vu=Vanuatu\n# suppress inspection \"UnusedProperty\"\nenum.country.ve=Venezuela\n# suppress inspection \"UnusedProperty\"\nenum.country.vn=Vietnam\n# suppress inspection \"UnusedProperty\"\nenum.country.vg=Virgin Islands, British\n# suppress inspection \"UnusedProperty\"\nenum.country.vi=Virgin Islands, U.S.\n# suppress inspection \"UnusedProperty\"\nenum.country.wf=Wallis and Futuna\n# suppress inspection \"UnusedProperty\"\nenum.country.eh=Western Sahara\n# suppress inspection \"UnusedProperty\"\nenum.country.ye=Yemen\n# suppress inspection \"UnusedProperty\"\nenum.country.zm=Zambia\n# suppress inspection \"UnusedProperty\"\nenum.country.zw=Zimbabwe\n# suppress inspection \"UnusedProperty\"\nenum.country.tor=Tor\n# suppress inspection \"UnusedProperty\"\nenum.country.i2p=I2P\n# suppress inspection \"UnusedProperty\"\nenum.country.lan=LAN\n"
  },
  {
    "path": "common/src/main/resources/i18n/messages_es.properties",
    "content": "#\n# Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n\n# Common\n\nok=Aceptar\ncancel=Cancelar\nclose=Cerrar\nsend=Enviar\ncreate=Crear\nremove=Eliminar\ndownload=Descargar\nadd=Agregar\nopen=Abrir\ncopy-link=Copiar dirección del enlace\ncopy=Copiar\nsave-as=Guardar como...\npaste-id=Pegar ID propio\nundo=Deshacer\nredo=Rehacer\ncut=Cortar\npaste=Pegar\ndelete=Eliminar\nselect-all=Seleccionar todo\ndeselect-all=Deseleccionar todo\nview-fullscreen=Ver pantalla completa\ncopy-image=Copiar imagen\nsave-image-as=Guardar imagen como...\nenabled=Habilitado\nno-results=No se encontraron resultados\nskip=Omitir\nname=Nombre\nhelp=Ayuda\nsettings=Configuración\nexit=Salir\nprofile=Perfil\nsubscribed=Suscrito\nown=Propios\ndescription=Descripción\nsubject=Asunto\nhash=Hash\nsize=Tamaño\ntrust=Confiar\nunknown-lc=desconocido\nlogo=Logotipo\nlatest=Último\nupdate=Actualizar\nedit=Editar\nbody=Texto del cuerpo (opcional)\ntext=Texto\nimage=Imagen\nlink=Enlace\nmark-read-unread=Marcar como leído/no leído\nthumbnail=Miniatura\nposts-at-remote-nodes=Publicaciones en nodo remoto\nlast-activity=Última actividad\nstate=Estado\nip=IP\nport=Puerto\n\n# File Requesters\n\nfile-requester.profiles=Archivos de perfil\nfile-requester.xml=Archivos XML\nfile-requester.png=Archivos PNG\nfile-requester.sounds=Archivos de sonido\nfile-requester.select-sound-title=Seleccionar archivo de sonido\nfile-requester.images=Archivos de imagen\nfile-requester.save-image-title=Seleccionar dónde guardar la imagen\nfile-requester.error=Error con el archivo {0}: {1}\nfile-requester.add-files=Seleccionar archivo(s) para agregar\n\n# Main\n\n## Menu\n\nmain.menu.add-peer=Agregar par...\nmain.menu.broadcast=Transmitir...\nmain.menu.shares=Configurar compartidos\nmain.menu.statistics=Mostrar estadísticas\n\nmain.menu.tools=Herramientas\nmain.menu.tools.import-from-rs=Importar amigos desde Retroshare...\nmain.menu.tools.export=Exportar...\n\nmain.menu.help.about=Acerca de Xeres\nmain.menu.help.documentation=Documentación\nmain.menu.help.report-bug=Reportar error ↗\nmain.menu.help.check-for-updates=Buscar actualizaciones... ↗\n\nmain.friends-import-successful=Se importaron {0} ubicaciones exitosamente.\nmain.friends-import-errors=Se importaron {0} ubicaciones, pero {1} tuvieron errores.\n\nmain.systray.peers={0,number,integer} pares conectados\n\n## Splash\n\nsplash.status.database=Cargando base de datos\nsplash.status.network=Iniciando red\n\n## Content\n\nmain.home=Inicio\nmain.contacts=Contactos\nmain.chats=Chats\nmain.forums=Foros\nmain.files=Archivos\nmain.boards=Boards\nmain.channels=Canales\n\nmain.home.slogan=Donde la amistad se encuentra con la libertad\nmain.home.share-id=Este es tu ID de Xeres. Compártelo con otras personas.\nmain.home.received-id=¿Recibiste un ID de un par?\nmain.home.add-peer=Agregar par\nmain.home.add-peer.tip=Agregar un amigo pegando su ID\nmain.home.need-help=¿Necesitas ayuda?\nmain.home.online-help=Ayuda en línea ↗\nmain.home.online-help.tip=Mostrar la ayuda en línea (Ctrl+F1)\nmain.home.copy-id.tip=Copiar ID de Xeres al portapapeles\nmain.home.qrcode.tip=Usa el código QR para transferir tu ID. Imprímelo o toma una foto con tu teléfono y luego muéstralo a una cámara web.\n\nmain.select-avatar=Seleccionar imagen de avatar\nmain.export-profile=Seleccionar dónde guardar tu perfil\nmain.import-friends=Seleccionar el archivo de amigos de Retroshare\n\nmain.scanning=Escaneando {0}...\nmain.hashing=Calculando hash de {0}\nmain.scanning.tip=Compartido: {0}, archivo: {1}\n\n## Status\n\nmain.status.connections=Conexiones:\nmain.status.nat.unknown=El estado aún es desconocido.\nmain.status.nat.firewalled=El cliente no es accesible desde conexiones iniciadas desde Internet.\nmain.status.nat.upnp=UPNP está activo y el cliente es completamente accesible desde Internet.\nmain.status.dht.disabled=DHT está deshabilitado.\nmain.status.dht.initializing=DHT se está inicializando. Esto puede tomar un tiempo.\nmain.status.dht.running=DHT está funcionando correctamente, la dirección IP del cliente se anuncia a sus pares.\nmain.status.dht.stats=Número de pares: {0,number,integer}\\nPaquetes recibidos: {1,number,integer} ({2})\\nPaquetes enviados: {3,number,integer} ({4})\\nRecuento de claves: {5,number,integer}\\nRecuento de elementos: {6,number,integer}\n\nmain.exit.confirm=¿Estás seguro de que quieres salir de Xeres?\n\n# Account creation\n\naccount.welcome=Bienvenido a Xeres\naccount.welcome.tip=Necesitas crear un perfil y una ubicación.\\n\\nEl perfil eres tú, puedes usar tu nombre o apodo, mientras que la ubicación es la máquina en la que estás.\\n\\nPuedes tener varias ubicaciones como una computadora de escritorio y una portátil que usen el mismo perfil (tú).\\n\\nUsa la opción de importar para importar un perfil que ya hayas creado antes.\\n\\nTodo siempre se almacena localmente, así que no olvides hacer una copia de seguridad de tus datos.\\n\\nPresiona la tecla F1 para leer la documentación incorporada, y recuerda que dejar el puntero del mouse sobre un elemento de la interfaz por un momento describirá qué es.\naccount.profile.prompt=Nombre del perfil\naccount.profile.tip=Usa un apodo o nombre real. Un perfil puede tener varias ubicaciones.\naccount.location=Ubicación\naccount.location.prompt=Nombre de la ubicación\naccount.location.tip=Esta es tu instancia de Xeres en este dispositivo. Usa el apodo o modelo de tu dispositivo.\naccount.options=Opciones\naccount.generation.profile-keys=Generando claves del perfil...\naccount.generation.location-keys-and-certificate=Generando claves de ubicación y certificado...\naccount.generation.identity=Generando identidad...\naccount.generation.profile-load=Selecciona un archivo de perfil de Xeres (xeres_backup.xml), un llavero de Retroshare (retroshare_secret_keyring.gpg) o un perfil de Retroshare (*.asc)\naccount.generation.import=Importar...\naccount.generation.import.tip=Puedes importar 3 tipos de perfiles:\\n\\nUn perfil exportado desde Xeres (xeres_backup.xml).\\n\\nUn llavero de Retroshare (retroshare_secret_keyring.gpg) o un perfil exportado desde Retroshare (*.asc).\naccount.generation.import.progress=Importando perfil...\naccount.generation.import.confirm.title=Importador de Retroshare\naccount.generation.import.confirm.prompt=Ingresa la contraseña de Retroshare\naccount.generation.import.unknown=Formato de archivo desconocido\n\n# Chat\n\n## Common\n\nchat.notification.typing={0} está escribiendo\n\n## Room common\n\nchat.room.id=ID\nchat.room.topic=Tema\nchat.room.security=Seguridad\nchat.room.users=Usuarios\n\nchat.room.info=Tema: {0}\\nUsuarios: {1,number,integer}\\nSeguridad: {2}\\nID: {3}\nchat.room.none=[ninguno]\nchat.room.private=privado\nchat.room.public=público\nchat.room.signed-only=solo IDs firmados\nchat.room.anonymous-allowed=IDs anónimos permitidos\nchat.room.user-info=Nombre: {0}\\nID: {1}\nchat.room.user-menu=Información\nchat.room.clear-history=¿Realmente quieres borrar el historial?\nchat.room.copy-selection=Copiar selección\nchat.room.clear-chat-history=Borrar historial de chat\n\n## Room create\n\nchat.room.create.window-title=Crear sala de chat\nchat.room.create.name.prompt=Nombre corto y descriptivo de la sala\nchat.room.create.name.tip=Nombre de la sala. Usa mayúsculas y espacios apropiados.\nchat.room.create.topic.prompt=De qué trata la sala\nchat.room.create.topic.tip=La descripción de la sala, de qué trata.\nchat.room.create.visibility=Visibilidad\nchat.room.create.visibility.tip=Las salas públicas son visibles para los pares.\\nLas salas privadas no lo son y funcionan solo por invitación.\nchat.room.create.security.checkbox=Solo identidades firmadas\nchat.room.create.security.tip=Una sala restringida a identidades firmadas es más resistente al spam porque las identidades anónimas no pueden unirse.\nchat.room.create.tooltip=Crear una nueva sala de chat\n\n## Room invite\n\nchat.room.invite.window-title=Invitar par a la sala de chat actual\nchat.room.invite.button=Invitar\nchat.room.invite.tip=Invitar pares a la sala de chat actual\nchat.room.invite.request={0} quiere invitarte a {1} ({2})\n\nchat.room.join=Unirse\nchat.room.leave=Salir\n\nchat.room.not-found=Sala no encontrada. Es probable que la sala no esté disponible en ninguno de tus amigos conectados.\n\n# Forums\n\ngxs-group.tree.popular=Populares\ngxs-group.tree.other=Otros\n\ngxs-group.tree.info=Nombre: {0}\\nID: {1}\\nMensajes remotos: {2}\\nActividad remota: {3}\n\ngxs-group.tree.subscribe=Suscribirse\ngxs-group.tree.unsubscribe=Cancelar suscripción\n\nforum.new-message.window-title=Nuevo mensaje\nforum.create.window-title=Crear foro\nforum.create.name.prompt=Nombre corto y descriptivo del foro\nforum.create.name.tip=Nombre del foro. Usa mayúsculas y espacios adecuados.\nforum.create.description.prompt=De qué trata el foro\nforum.create.description.tip=La descripción del foro, de qué trata.\n\nforum.editor.name=Foro\nforum.editor.name.prompt=Nombre del foro\nforum.editor.thread.description=El asunto del hilo\nforum.editor.cancel=¡El mensaje del foro no ha sido enviado aún! ¿Realmente quieres descartar este mensaje?\n\nforum.view.create.tip=Crear un nuevo foro\nforum.view.header.author=Autor\nforum.view.header.date=Fecha\n\nforum.view.new-message.tip=Crear un nuevo mensaje\n\nforum.view.group.not-found=Foro no encontrado. Es probable que no esté disponible en ninguno de tus amigos conectados.\nforum.view.message.not-found=Mensaje no encontrado. Es probable que el mensaje sea demasiado antiguo o que el originador tenga una reputación demasiado baja.\n\nforum.view.from=De:\nforum.view.subject=Asunto:\nforum.view.reply=Responder\n\nforum.view.history=Este selector permite mostrar versiones anteriores de los mensajes.\n\n# Boards\n\nboard.create.window-title=Crear tablón\nboard.create.name.prompt=Nombre corto y descriptivo del tablón\nboard.create.name.tip=Nombre del tablón. Use mayúsculas y espacios adecuadamente.\nboard.create.description.prompt=De qué trata el tablón\nboard.create.description.tip=La descripción del tablón, de qué trata.\nboard.select-logo=Seleccionar imagen del tablón\nboard.select-image=Seleccionar imagen para publicar\n\nboard.view.create.tip=Crear un nuevo tablón\nboard.view.group.not-found=Tablón no encontrado. Es probable que no esté disponible en ninguno de tus amigos conectados.\n\nboard.new-message.window-title=Nueva publicación en el tablón\n\nboard.editor.name=Tablón\nboard.editor.name.prompt=El nombre del tablón\nboard.editor.thread.title=Título\nboard.editor.post.description=El título de la publicación\n\nboard.editor.cancel=¡La publicación en el tablón aún no se ha enviado! ¿Realmente quieres descartar esta publicación?\n\nboard.posted-by=Publicado por\nboard.on=el\n\n# Channels\n\nchannel.view.create.tip=Crear un nuevo canal\nchannel.create.window-title=Crear canal\nchannel.create.name.prompt=Nombre corto y descriptivo del canal\nchannel.create.name.tip=Nombre del canal. Use mayúsculas y espacios adecuadamente.\nchannel.create.description.prompt=De qué trata el canal\nchannel.create.description.tip=La descripción del canal, de qué trata.\nchannel.select-image=Seleccionar imagen para la publicación del canal\n\nchannel.view.group.not-found=Canal no encontrado. Es probable que no esté disponible en ninguno de tus amigos conectados.\n\nchannel.select-logo=Seleccionar imagen del canal\n\nchannel.new-message.window-title=Nueva publicación en el canal\n\nchannel.editor.name=Canal\nchannel.editor.name.prompt=El nombre del canal\nchannel.editor.thread.title=Título\nchannel.editor.post.description=El título de la publicación\n\nchannel.editor.cancel=¡La publicación en el canal aún no se ha enviado! ¿Realmente quieres descartar esta publicación?\nchannel.clipboard.error=El portapapeles no contiene enlaces de archivos.\nchannel.files=Archivos\nchannel.post=Publicar\nchannel.drag-drop=Agregar archivos o arrastrarlos y soltarlos aquí\nchannel.add-files=Agregar archivo(s)\nchannel.paste-links=Pegar enlace(s)\nchannel.remove-files=Eliminar archivo(s)\n\n# Add RSID\n\nrs-id.add.window-title=Agregar Par\nrs-id.add.textarea.prompt=Pega el ID del par\nrs-id.add.textarea.tip=El ID es una cadena de alrededor de cien caracteres base64. Codifica toda la información necesaria para conectarse a un par.\nrs-id.add.details=Detalles del par\nrs-id.add.name.tip=Nombre del par, asegúrate de saber quién es.\nrs-id.add.profile=ID de perfil\nrs-id.add.profile.tip=ID único para verificar si el perfil de tu par es el correcto.\nrs-id.add.fingerprint=Huella digital\nrs-id.add.fingerprint.tip=Suma de verificación criptográfica para certificar la autenticidad del perfil de tu par.\nrs-id.add.location=ID de ubicación\nrs-id.add.location.tip=Identificador de ubicación. Un perfil puede tener varias ubicaciones y cada una tiene un ID único.\nrs-id.add.addresses=Direcciones\nrs-id.add.addresses.tip=Direcciones para conectarse. Todas se intentarán en turno, pero puedes preseleccionar la mejor para una conexión inicial más rápida.\\nLas direcciones que terminan en .onion requieren usar un proxy Tor.\\nLas direcciones que terminan en .i2p requieren usar un proxy I2P.\nrs-id.add.trust.tip=El nivel de confianza que tienes en el par.\\nDesconocido: sin opinión.\\nNunca: ninguna o mínima, conocido en línea recientemente.\\nMarginal: más o menos confiable, conocido.\\nCompleto: muy confiable, buen amigo.\nrs-id.add.invalid=ID inválido\nrs-id.add.scan=Escanea el código QR usando la cámara.\n\n# Broadcast\n\nbroadcast.window-title=Transmitir\nbroadcast.send.explanation=Enviar un mensaje a todos los pares actualmente conectados.\nbroadcast.send.warning-header=Advertencia:\nbroadcast.send.warning=no abuses de esta función. Solo úsala para emergencias o situaciones excepcionales.\n\n# Messaging\n\nmessaging.prompt=Escribe un mensaje\nmessaging.file-requester.send-picture=Seleccionar imagen para enviar en línea\nmessaging.file-requester.send-file=Seleccionar archivo para enviar\nmessaging.send-picture=Seleccionar una imagen para enviar en línea\nmessaging.send-sticker=Enviar un sticker\nmessaging.send.file=Seleccionar un archivo para enviar\nmessaging.action.call=Hacer una llamada directa\nmessaging.action.send-inline=Enviar una imagen en línea\nmessaging.action.send-file=Enviar un archivo\n\nmessaging.warning.title=Advertencia\nmessaging.warning.description=El usuario está actualmente desconectado y no puede recibir mensajes.\n\nmessaging.tunneling=Intentando establecer túnel...\n\nmessaging.closing-tunnel.confirm=Cerrar esta ventana terminará el chat distante y descartará todos los mensajes no enviados. ¿Estás seguro?\n\n# Profiles\n\nprofiles.delete=Eliminar perfil\n\n# About\n\nabout.window-title=Acerca de {0}\nabout.version=Versión:\nabout.title=Acerca de\nabout.slogan=Una aplicación Friend-to-Friend, descentralizada y segura para comunicación y compartir\nabout.authors=Autores\nabout.author-by=por\nabout.all-rights-reserved=Todos los derechos reservados\nabout.report-bugs=Reportar errores o sugerir mejoras.\nabout.website=Sitio web\nabout.wiki=Wiki\nabout.source-code=Código fuente\nabout.thanks=Agradecimientos a\nabout.license=Licencia\nabout.additional-licenses=Licencias adicionales\nabout.release=Versión\nabout.profiles=Perfiles:\n\n# QR Code\n\nqr-code.window-title=Código QR\nqr-code.print=Imprimir...\nqr-code.save-as-png=Guardar como PNG\nqr-code.download-client=Descargar cliente en https://xeres.io\nqr-code.camera.error=No se ha detectado ninguna cámara\n\n# Camera\n\ncamera.window-title=Escanear código QR\n\n# Settings\n\n## Main\n\nsettings.general=General\nsettings.network=Red\nsettings.transfer=Transferencia\nsettings.notifications=Notificaciones\nsettings.sound=Sonido\nsettings.remote=Remoto\nsettings.directory.no-remote=No se puede elegir un directorio en modo remoto\n\n## General\n\nsettings.general.theme=Tema\nsettings.general.system=Sistema\nsettings.general.startup=Ejecutar al inicio del sistema\nsettings.general.startup.tip=Ejecutar automáticamente cuando el sistema inicia, minimizado en la bandeja.\nsettings.general.startup.not-available=No disponible. O el sistema operativo no es compatible o estás ejecutando en modo portátil.\nsettings.general.update-check=Buscar actualizaciones automáticamente\nsettings.general.update-check.tip=Verifica automáticamente GitHub una vez al día para ver si hay una nueva versión.\n\n## Network\n\nsettings.network.hidden-services=Servicios ocultos\nsettings.network.tor-proxy=Proxy Socks de Tor\nsettings.network.tor-proxy.prompt=Servidor Tor\nsettings.network.tor-proxy.tip=La dirección IP o nombre de host del SOCKS v5 de Tor, generalmente 127.0.0.1 si se ejecuta en el mismo host.\nsettings.network.tor-port.tip=El puerto SOCKS v5 de Tor, generalmente 9050.\nsettings.network.i2p-proxy=Proxy Socks de I2P\nsettings.network.i2p-proxy.prompt=Servidor I2P\nsettings.network.i2p-proxy.tip=La dirección IP o nombre de host del SOCKS v5 de I2P, generalmente 127.0.0.1 si se ejecuta en el mismo host.\nsettings.network.i2p-port.tip=El puerto SOCKS v5 de I2P, generalmente 4447.\nsettings.network.use-upnp=Usar UPNP\nsettings.network.use-upnp.tip=UPNP (Universal Plug and Play) permite configurar automáticamente los puertos entrantes correctos en tu router. Esto mejora la confiabilidad de la conexión desde tus pares.\nsettings.network.external-ip-and-port=IP y puerto externos\nsettings.network.external-ip-and-port.tip=La dirección IP externa y puerto de tu ubicación. Así es como aparece tu conexión en Internet.\nsettings.network.use-broadcast-discovery=Habilitar descubrimiento por broadcast\nsettings.network.use-broadcast-discovery.tip=El descubrimiento por broadcast permite informar tu IP y puerto a otras ubicaciones en tu LAN. Esto mejora la confiabilidad de la conexión desde posibles pares en tu LAN.\nsettings.network.internal-ip-and-port=IP y puerto internos\nsettings.network.internal-ip-and-port.tip=La dirección IP interna y puerto de tu ubicación. Así es como aparece tu conexión en tu LAN (Red de área local).\nsettings.network.use-dht=Habilitar DHT\nsettings.network.use-dht.tip=El DHT (Tabla hash distribuida) permite a los pares encontrar la dirección IP del otro cuando cambia. Esto mejora la conectividad cuando se está en movimiento.\n\n## Remote\n\nsettings.remote.title=Acceso remoto\nsettings.remote.username=Nombre de usuario\nsettings.remote.password=Contraseña\nsettings.remote.note=Establecer una contraseña vacía deshabilita la autenticación.\nsettings.remote.enabled.tip=Habilitar acceso remoto. Esta instancia puede entonces ser accedida desde otra instancia de Xeres o desde el cliente Android.\nsettings.remote.upnp-set=Establecer con UPNP\nsettings.remote.upnp-set.tip=Establecer el puerto remoto con UPNP, haciéndolo accesible desde la WAN.\nsettings.remote.restart=¿Necesitas reiniciar Xeres para que los cambios de acceso remoto sean efectivos? ¿Salir ahora?\nsettings.remote.view-api=Consultar el API\n\n## Transfer\n\nsettings.transfer.select-incoming=Seleccionar directorio de entrada\nsettings.transfer.incoming=Directorio de entrada\n\n## Notifications\n\nsettings.notifications.show-connections=Mostrar conexiones\nsettings.notifications.show-connections.tip=Muestra cuando se establece una conexión con un amigo.\nsettings.notifications.show-broadcasts=Mostrar transmisiones\nsettings.notifications.show-broadcasts.tip=Muestra transmisiones de mensajes enviadas por amigos.\nsettings.notifications.show-discovery=Mostrar descubrimiento\nsettings.notifications.show-discovery.tip=Muestra cuando un cliente que tiene habilitado el descubrimiento por broadcast aparece en la LAN.\n\n## Sound\n\nsettings.sound.message=Mensaje recibido\nsettings.sound.message.tip=Reproduce un sonido cuando se recibe un mensaje privado y la ventana está inactiva.\nsettings.sound.highlight=Resaltar\nsettings.sound.highlight.tip=Reproduce un sonido cuando alguien se dirige a ti en una sala de chat.\nsettings.sound.friend=Amigo conectado\nsettings.sound.friend.tip=Reproduce un sonido cuando un amigo se conecta a ti.\nsettings.sound.download=Descarga completada\nsettings.sound.download.tip=Reproduce un sonido cuando se completa una descarga.\nsettings.sound.ringing=Llamada\nsettings.sound.ringing.tip=Reproduce un sonido al recibir o realizar una llamada.\n\n# Share\n\nshare.window-title=Compartidos\nshare.select-directory=Seleccionar directorio para compartir\nshare.remove=Eliminar compartido\nshare.error.empty-name=El nombre del compartido no puede estar vacío. Establece un nombre único.\nshare.error.empty-path=La ruta del compartido no puede estar vacía. Establece una ruta de compartido.\nshare.error.not-unique=El nombre del compartido ya existe. Cada nombre de compartido debe ser único.\n\nshare.list.directory=Directorio compartido\nshare.list.visible-name=Nombre visible\nshare.list.searchable=Buscable\nshare.list.browsable=Navegable\n\nshare.create=Crear un nuevo compartido\nshare.apply=Aplicar y cerrar\n\n# Tray\n\ntray.open=Abrir {0}\ntray.peers=Pares\ntray.status=Estado\n\n# EditorView\n\neditor.hyperlink.enter=Ingresar URL\neditor.action.undo=Deshacer (Ctrl+Z)\neditor.action.redo=Rehacer (Ctrl+Shift+Z)\neditor.action.bold=Negrita (Ctrl+B)\neditor.action.italic=Cursiva (Ctrl+I)\neditor.action.hyperlink=Enlace (Ctrl+L)\neditor.action.quote=Cita (Ctrl+Q)\neditor.action.code=Código (Ctrl+K)\neditor.action.unordered-list=Lista desordenada (Ctrl+U)\neditor.action.ordered-list=Lista ordenada (Ctrl+Shift+U)\neditor.action.header=Encabezado (Ctrl+1)\neditor.action.preview=Vista previa del mensaje (F12)\n\n# Search / Download / Uploads\n\nsearch.main.search=Buscar\nsearch.main.downloads=Descargas\nsearch.main.uploads=Subidas\nsearch.main.trends=Tendencias\n\nsearch.input.prompt=Ingresar términos de búsqueda\nsearch.input.search.tip=Escribir varios términos de búsqueda buscará archivos que contengan todos ellos. Usa \" alrededor de los términos de búsqueda para una coincidencia exacta.\nsearch.searching=Buscando...\n\ntrends.none=Aún no hay tendencias\ntrends.list.terms=Términos\ntrends.list.from=De\ntrends.list.time=Tiempo\n\ndownload-view.list.none=No hay descargas\ndownload-view.list.state=Estado\ndownload-view.list.progress=Progreso\ndownload-view.list.total-size=Tamaño total\n\ndownload-view.show-in-folder=Mostrar en carpeta\n\ndownload-view.open-error=Error al abrir:\ndownload-view.show-error=Error al mostrar en el explorador:\n\ndownload-add.window-title=Agregar descarga\ndownload-add.bytes={0,number,integer} bytes\n\nupload-view.none=No se están subiendo archivos\n\nfile-result.column.type=Tipo\n\n# StatisticsTurtle\n\nstatistics.window-title=Estadísticas\n\nstatistics.elapsed-time=Tiempo transcurrido (segundos)\n\nstatistics.turtle.data-in=Datos de entrada\nstatistics.turtle.data-in.tip=Los datos de contenido recibidos (descargas)\nstatistics.turtle.data-out=Datos de salida\nstatistics.turtle.data-out.tip=Los datos de contenido enviados (subidas)\nstatistics.turtle.data-forward=Datos reenviados\nstatistics.turtle.data-forward.tip=Los datos de contenido reenviados a otros pares\nstatistics.turtle.tunnel-in=Solicitudes de túnel entrantes\nstatistics.turtle.tunnel-in.tip=Solicitudes de túnel entrantes\nstatistics.turtle.tunnel-out=Solicitudes de túnel salientes\nstatistics.turtle.tunnel-out.tip=Solicitudes de túnel reenviadas y propias solicitudes\nstatistics.turtle.search-in=Solicitudes de búsqueda entrantes\nstatistics.turtle.search-in.tip=Solicitudes de búsqueda entrantes\nstatistics.turtle.search-out=Solicitudes de búsqueda salientes\nstatistics.turtle.search-out.tip=Solicitudes de búsqueda reenviadas y nuestras propias solicitudes\nstatistics.turtle.bandwidth=Ancho de banda\nstatistics.turtle.speed=Velocidad (KB/s)\nstatistics.turtle.tip=El gráfico muestra las estadísticas del router turtle. Consiste en:\\nSolicitudes de búsqueda: búsquedas de archivos (título, tamaño, etc...).\\nSolicitudes de túnel: configuraciones de túnel entre pares remotos para preparar una transferencia de archivos.\\nSolicitudes de datos: datos que fluyen dentro de túneles.\\nLa mayoría de las solicitudes y datos que no están destinados a nuestro propio nodo se reenvían a otros pares, dentro de una probabilidad dada que varía según la distancia.\n\nstatistics.rtt.rtt=Tiempo de ida y vuelta\nstatistics.rtt.time=RTT (milisegundos)\nstatistics.rtt.tip=El RTT (Round Trip Time), es la cantidad de tiempo que tarda un mensaje en enviarse a un destino y que se envíe una respuesta al remitente. Da una idea de la latencia de la red entre pares.\\nEspera problemas si el RTT es demasiado alto (más de unos segundos).\n\nstatistics.data-counter.title=Uso de datos\nstatistics.data-counter.data=Datos (KB)\nstatistics.data-counter.tip=Este gráfico muestra la cantidad de datos que entran y salen hacia los pares.\nstatistics.data-counter.peers=Pares\n\nstatistics.turtle=Turtle\nstatistics.rtt=RTT\nstatistics.data-usage=Uso de datos\n\n# ContactView\n\ncontact-view.profile-delete.confirm=Esto eliminará y desconectará tu perfil {0}. ¿Realmente quieres hacerlo?\ncontact-view.avatar-delete.confirm=¿Realmente quieres eliminar tu imagen de avatar?\ncontact-view.location.last-connected.now=Ahora\ncontact-view.location.last-connected.never=Nunca\ncontact-view.information.linked-to-profile=Identidad vinculada al perfil\ncontact-view.information.profile=Perfil\ncontact-view.information.identity=Identidad\ncontact-view.information.type=Tipo\ncontact-view.information.created=Creado\ncontact-view.information.updated=Actualizado\ncontact-view.information.created-unknown=desconocido\ncontact-view.information.key-information-with-length=Versión: {0}\\nAlgoritmo: {1}\\nLongitud: {2} bits\\nHash de firma: {3}\ncontact-view.information.key-information=Versión: {0}\\nAlgoritmo: {1}\\nHash de firma: {2}\ncontact-view.open.identity-not-found=Identidad no encontrada\ncontact-view.open.profile-not-found=Perfil no encontrada\ncontact-view.information.location.id=ID de ubicación:\ncontact-view.information.location.version=Versión:\ncontact-view.search.prompt=Buscar personas\ncontact-view.search.show-all=Mostrar todos los contactos\ncontact-view.search.no-contacts=No hay contactos\ncontact-view.badge.own=Propio\ncontact-view.badge.own.tip=Este eres tú.\ncontact-view.badge.partial=Parcial\ncontact-view.badge.partial.tip=Un contacto parcial no está respaldado por un perfil completo aún. Necesita conectarse al menos una vez, luego se verificará y, si tiene éxito, se promoverá a un perfil completo.\ncontact-view.badge.accepted=Aceptado\ncontact-view.badge.accepted.tip=Este contacto es aceptado para conexiones entrantes y también se intentan conexiones salientes hacia él.\ncontact-view.badge.not-validated=Aún no validado\ncontact-view.badge.not-validated.tip=El contacto no ha sido validado aún. Su firma de perfil se verificará shortly y, si tiene éxito, se marcará como válido. Si no tiene éxito, se eliminará (pero podría transferirse nuevamente, si es así, intenta informar a su propietario sobre el problema).\ncontact-view.action.chat=Chat\ncontact-view.action.distant-chat=Chat distante\ncontact-view.action.connect=Intentar conectar\ncontact-view.information.locations=Ubicaciones\ncontact-view.column.last-connected=Última conexión\ncontact-view.chat.start=Iniciar chat directo\ncontact-view.distant-chat.start=Iniciar chat distante\n\n# ImageSelectorView\n\nimage-selector-view.change-image=Cambiar imagen...\nimage-selector-view.change-image-short=Cambiar\nimage-selector-view.add-image=Agregar imagen...\n\n# VoIP\n\nvoip.window-title=Llamada\nvoip.action.message=Mensaje\nvoip.action.message.tip=Enviar un mensaje de chat directo al usuario\nvoip.action.recall=Llamar nuevamente\nvoip.action.recall.tip=Llamar al usuario de nuevo\nvoip.action.close.tip=Cerrar la ventana\nvoip.action.answer=Contestar\nvoip.action.reject=Rechazar\nvoip.action.hangup=Colgar\nvoip.action.window-quit=¿Estás seguro de que quieres abortar la llamada?\nvoip.status.incoming=Llamada entrante...\nvoip.status.calling=Llamando...\nvoip.status.ongoing=En llamada\nvoip.status.ended=Llamada terminada\n\n# Update\n\nupdate.latest-already=Ya tienes la última versión.\nupdate.new-version=Hay una nueva versión disponible ({0}). ¿Descargar, verificar e instalar?\nupdate.new-version-auto=Hay una nueva versión disponible ({0}).\nupdate.download-failure=No se pudo descargar la URL y/o la URL de firma\nupdate.download-file=Descargando archivo...\nupdate.download.title=Actualizador de Xeres\nupdate.download.verifying=Verificando archivo...\nupdate.download.install=Instalar\nupdate.download.install-ready=¡Listo para instalar!\nupdate.download-verification-failed=¡La verificación falló!\n\n# Stickers\n\nstickers.instructions=Agrega tus stickers en {0}\\n\\nUn directorio por colección de stickers, cada uno conteniendo PNGs o JPEGs.\n\n# ChatCommands\n\nchat-command.code=Enviar el texto como un bloque de código\nchat-command.coin=Lanzar una moneda\nchat-command.me=Enviar un mensaje de acción en tercera persona\nchat-command.pre=Enviar el texto como preformateado\nchat-command.quote=Enviar el texto como una cita\nchat-command.random=Enviar un número aleatorio del 1 al 10\nchat-command-send=Enviar {0}\n\n# Misc\n\nuri.malicious-link=¡Advertencia! Este es un enlace malicioso, hacer clic te llevará a: {0}\nuri.unsafe-link=¡Advertencia! Este enlace podría no ser seguro, al hacer clic irás a: {0}\nuri.malicious-link.confirm=¡Advertencia! Este es un enlace malicioso, te llevará a {0}. ¿Realmente quieres?\nuri.unsafe-link.confirm=¡Advertencia! Este enlace podría no ser seguro, te llevará a {0}. ¿Sabes lo que es y confías en él?\n\ncontent-image.exit=Presiona ESC o haz clic para salir\n\nwebsocket.disconnected=Conexión WebSocket perdida. Chat no disponible. ¿Reconectar?\n\n# TrustConverter\n\ntrust-converter.nobody=Nadie\ntrust-converter.everybody=Todos\ntrust-converter.marginal=Fideicomisarios marginales\ntrust-converter.full=Fideicomisarios completos\ntrust-converter.ultimate=Solo yo\n\n# Byte units\n\nbyte-unit.invalid=inválido\nbyte-unit.bytes=bytes\nbyte-unit.kb=KB\nbyte-unit.mb=MB\nbyte-unit.gb=GB\nbyte-unit.tb=TB\nbyte-unit.pb=PB\nbyte-unit.eb=EB\n\n# Help\n\nhelp.back=Retroceder en el historial\nhelp.forward=Avanzar en el historial\nhelp.home=Ir a la sección de inicio\n\n# Enums (beware of the key naming which must be the same as the class!)\n\n## Trust\n\n# suppress inspection \"UnusedProperty\"\nenum.trust.unknown=Desconocido\n# suppress inspection \"UnusedProperty\"\nenum.trust.never=Nunca\n# suppress inspection \"UnusedProperty\"\nenum.trust.marginal=Marginal\n# suppress inspection \"UnusedProperty\"\nenum.trust.full=Completo\n# suppress inspection \"UnusedProperty\"\nenum.trust.ultimate=Máximo\n\n## Availability\n\n# suppress inspection \"UnusedProperty\"\nenum.availability.available=Disponible\n# suppress inspection \"UnusedProperty\"\nenum.availability.busy=Ocupado\n# suppress inspection \"UnusedProperty\"\nenum.availability.away=Ausente\n# suppress inspection \"UnusedProperty\"\nenum.availability.offline=Desconectado\n\n## RoomType\n\n# suppress inspection \"UnusedProperty\"\nenum.room-type.private=Privado\n# suppress inspection \"UnusedProperty\"\nenum.room-type.public=Público\n\n## FileType\n\n# suppress inspection \"UnusedProperty\"\nenum.file-type.any=Cualquiera\n# suppress inspection \"UnusedProperty\"\nenum.file-type.audio=Audio\n# suppress inspection \"UnusedProperty\"\nenum.file-type.archive=Archivo\n# suppress inspection \"UnusedProperty\"\nenum.file-type.document=Documento\n# suppress inspection \"UnusedProperty\"\nenum.file-type.picture=Imagen\n# suppress inspection \"UnusedProperty\"\nenum.file-type.program=Programa\n# suppress inspection \"UnusedProperty\"\nenum.file-type.video=Video\n# suppress inspection \"UnusedProperty\"\nenum.file-type.subtitles=Subtítulo\n# suppress inspection \"UnusedProperty\"\nenum.file-type.collection=Colección\n# suppress inspection \"UnusedProperty\"\nenum.file-type.directory=Directorio\n\n## FileProgressDisplay State\n\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.searching=Buscando\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.transferring=Transfiriendo\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.removing=Eliminando\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.done=Completado\n\n## FileAttachment State\n\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.hashing=Calculando Hash\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.done=Completado\n\n## Country\n\n# suppress inspection \"UnusedProperty\"\nenum.country.af=Afganistán\n# suppress inspection \"UnusedProperty\"\nenum.country.al=Albania\n# suppress inspection \"UnusedProperty\"\nenum.country.dz=Argelia\n# suppress inspection \"UnusedProperty\"\nenum.country.as=Samoa Americana\n# suppress inspection \"UnusedProperty\"\nenum.country.ad=Andorra\n# suppress inspection \"UnusedProperty\"\nenum.country.ao=Angola\n# suppress inspection \"UnusedProperty\"\nenum.country.ai=Anguila\n# suppress inspection \"UnusedProperty\"\nenum.country.aq=Antártida\n# suppress inspection \"UnusedProperty\"\nenum.country.ag=Antigua y Barbuda\n# suppress inspection \"UnusedProperty\"\nenum.country.ar=Argentina\n# suppress inspection \"UnusedProperty\"\nenum.country.am=Armenia\n# suppress inspection \"UnusedProperty\"\nenum.country.aw=Aruba\n# suppress inspection \"UnusedProperty\"\nenum.country.au=Australia\n# suppress inspection \"UnusedProperty\"\nenum.country.at=Austria\n# suppress inspection \"UnusedProperty\"\nenum.country.az=Azerbaiyán\n# suppress inspection \"UnusedProperty\"\nenum.country.ba=Bosnia y Herzegovina\n# suppress inspection \"UnusedProperty\"\nenum.country.bs=Bahamas\n# suppress inspection \"UnusedProperty\"\nenum.country.bh=Bahréin\n# suppress inspection \"UnusedProperty\"\nenum.country.bd=Bangladesh\n# suppress inspection \"UnusedProperty\"\nenum.country.bb=Barbados\n# suppress inspection \"UnusedProperty\"\nenum.country.by=Bielorrusia\n# suppress inspection \"UnusedProperty\"\nenum.country.be=Bélgica\n# suppress inspection \"UnusedProperty\"\nenum.country.bz=Belice\n# suppress inspection \"UnusedProperty\"\nenum.country.bj=Benín\n# suppress inspection \"UnusedProperty\"\nenum.country.bm=Bermudas\n# suppress inspection \"UnusedProperty\"\nenum.country.bt=Bután\n# suppress inspection \"UnusedProperty\"\nenum.country.bo=Bolivia\n# suppress inspection \"UnusedProperty\"\nenum.country.bw=Botsuana\n# suppress inspection \"UnusedProperty\"\nenum.country.bv=Isla Bouvet\n# suppress inspection \"UnusedProperty\"\nenum.country.br=Brasil\n# suppress inspection \"UnusedProperty\"\nenum.country.io=Territorio Británico del Océano Índico\n# suppress inspection \"UnusedProperty\"\nenum.country.bn=Brunei\n# suppress inspection \"UnusedProperty\"\nenum.country.bg=Bulgaria\n# suppress inspection \"UnusedProperty\"\nenum.country.bf=Burkina Faso\n# suppress inspection \"UnusedProperty\"\nenum.country.bi=Burundi\n# suppress inspection \"UnusedProperty\"\nenum.country.kh=Camboya\n# suppress inspection \"UnusedProperty\"\nenum.country.cm=Camerún\n# suppress inspection \"UnusedProperty\"\nenum.country.ca=Canadá\n# suppress inspection \"UnusedProperty\"\nenum.country.cv=Cabo Verde\n# suppress inspection \"UnusedProperty\"\nenum.country.ky=Islas Caimán\n# suppress inspection \"UnusedProperty\"\nenum.country.cf=República Centroafricana\n# suppress inspection \"UnusedProperty\"\nenum.country.td=Chad\n# suppress inspection \"UnusedProperty\"\nenum.country.cl=Chile\n# suppress inspection \"UnusedProperty\"\nenum.country.cn=China\n# suppress inspection \"UnusedProperty\"\nenum.country.cx=Isla de Navidad\n# suppress inspection \"UnusedProperty\"\nenum.country.cc=Islas Cocos (Keeling)\n# suppress inspection \"UnusedProperty\"\nenum.country.co=Colombia\n# suppress inspection \"UnusedProperty\"\nenum.country.km=Comoras\n# suppress inspection \"UnusedProperty\"\nenum.country.cg=Congo\n# suppress inspection \"UnusedProperty\"\nenum.country.cd=República Democrática del Congo\n# suppress inspection \"UnusedProperty\"\nenum.country.ck=Islas Cook\n# suppress inspection \"UnusedProperty\"\nenum.country.cr=Costa Rica\n# suppress inspection \"UnusedProperty\"\nenum.country.ci=Costa de Marfil\n# suppress inspection \"UnusedProperty\"\nenum.country.hr=Croacia\n# suppress inspection \"UnusedProperty\"\nenum.country.cu=Cuba\n# suppress inspection \"UnusedProperty\"\nenum.country.cy=Chipre\n# suppress inspection \"UnusedProperty\"\nenum.country.cz=República Checa\n# suppress inspection \"UnusedProperty\"\nenum.country.dk=Dinamarca\n# suppress inspection \"UnusedProperty\"\nenum.country.dj=Yibuti\n# suppress inspection \"UnusedProperty\"\nenum.country.dm=Dominica\n# suppress inspection \"UnusedProperty\"\nenum.country.do=República Dominicana\n# suppress inspection \"UnusedProperty\"\nenum.country.ec=Ecuador\n# suppress inspection \"UnusedProperty\"\nenum.country.eg=Egipto\n# suppress inspection \"UnusedProperty\"\nenum.country.sv=El Salvador\n# suppress inspection \"UnusedProperty\"\nenum.country.gq=Guinea Ecuatorial\n# suppress inspection \"UnusedProperty\"\nenum.country.er=Eritrea\n# suppress inspection \"UnusedProperty\"\nenum.country.ee=Estonia\n# suppress inspection \"UnusedProperty\"\nenum.country.et=Etiopía\n# suppress inspection \"UnusedProperty\"\nenum.country.fk=Islas Malvinas (Falkland)\n# suppress inspection \"UnusedProperty\"\nenum.country.fo=Islas Feroe\n# suppress inspection \"UnusedProperty\"\nenum.country.fj=Fiyi\n# suppress inspection \"UnusedProperty\"\nenum.country.fi=Finlandia\n# suppress inspection \"UnusedProperty\"\nenum.country.fr=Francia\n# suppress inspection \"UnusedProperty\"\nenum.country.gf=Guayana Francesa\n# suppress inspection \"UnusedProperty\"\nenum.country.pf=Polinesia Francesa\n# suppress inspection \"UnusedProperty\"\nenum.country.tf=Territorios Australes Franceses\n# suppress inspection \"UnusedProperty\"\nenum.country.ga=Gabón\n# suppress inspection \"UnusedProperty\"\nenum.country.gm=Gambia\n# suppress inspection \"UnusedProperty\"\nenum.country.ge=Georgia\n# suppress inspection \"UnusedProperty\"\nenum.country.de=Alemania\n# suppress inspection \"UnusedProperty\"\nenum.country.gh=Ghana\n# suppress inspection \"UnusedProperty\"\nenum.country.gi=Gibraltar\n# suppress inspection \"UnusedProperty\"\nenum.country.gr=Grecia\n# suppress inspection \"UnusedProperty\"\nenum.country.gl=Groenlandia\n# suppress inspection \"UnusedProperty\"\nenum.country.gd=Granada\n# suppress inspection \"UnusedProperty\"\nenum.country.gp=Guadalupe\n# suppress inspection \"UnusedProperty\"\nenum.country.gu=Guam\n# suppress inspection \"UnusedProperty\"\nenum.country.gt=Guatemala\n# suppress inspection \"UnusedProperty\"\nenum.country.gg=Guernsey\n# suppress inspection \"UnusedProperty\"\nenum.country.gn=Guinea\n# suppress inspection \"UnusedProperty\"\nenum.country.gw=Guinea-Bisáu\n# suppress inspection \"UnusedProperty\"\nenum.country.gy=Guyana\n# suppress inspection \"UnusedProperty\"\nenum.country.ht=Haití\n# suppress inspection \"UnusedProperty\"\nenum.country.hm=Islas Heard y McDonald\n# suppress inspection \"UnusedProperty\"\nenum.country.va=Santa Sede (Ciudad del Vaticano)\n# suppress inspection \"UnusedProperty\"\nenum.country.hn=Honduras\n# suppress inspection \"UnusedProperty\"\nenum.country.hk=Hong Kong\n# suppress inspection \"UnusedProperty\"\nenum.country.hu=Hungría\n# suppress inspection \"UnusedProperty\"\nenum.country.is=Islandia\n# suppress inspection \"UnusedProperty\"\nenum.country.in=India\n# suppress inspection \"UnusedProperty\"\nenum.country.id=Indonesia\n# suppress inspection \"UnusedProperty\"\nenum.country.ir=Irán\n# suppress inspection \"UnusedProperty\"\nenum.country.iq=Irak\n# suppress inspection \"UnusedProperty\"\nenum.country.ie=Irlanda\n# suppress inspection \"UnusedProperty\"\nenum.country.im=Isla de Man\n# suppress inspection \"UnusedProperty\"\nenum.country.il=Israel\n# suppress inspection \"UnusedProperty\"\nenum.country.it=Italia\n# suppress inspection \"UnusedProperty\"\nenum.country.jm=Jamaica\n# suppress inspection \"UnusedProperty\"\nenum.country.jp=Japón\n# suppress inspection \"UnusedProperty\"\nenum.country.je=Jersey\n# suppress inspection \"UnusedProperty\"\nenum.country.jo=Jordania\n# suppress inspection \"UnusedProperty\"\nenum.country.kz=Kazajistán\n# suppress inspection \"UnusedProperty\"\nenum.country.ke=Kenia\n# suppress inspection \"UnusedProperty\"\nenum.country.ki=Kiribati\n# suppress inspection \"UnusedProperty\"\nenum.country.kp=Corea del Norte\n# suppress inspection \"UnusedProperty\"\nenum.country.kr=Corea del Sur\n# suppress inspection \"UnusedProperty\"\nenum.country.kw=Kuwait\n# suppress inspection \"UnusedProperty\"\nenum.country.kg=Kirguistán\n# suppress inspection \"UnusedProperty\"\nenum.country.la=Laos\n# suppress inspection \"UnusedProperty\"\nenum.country.lv=Letonia\n# suppress inspection \"UnusedProperty\"\nenum.country.lb=Líbano\n# suppress inspection \"UnusedProperty\"\nenum.country.ls=Lesoto\n# suppress inspection \"UnusedProperty\"\nenum.country.lr=Liberia\n# suppress inspection \"UnusedProperty\"\nenum.country.ly=Libia\n# suppress inspection \"UnusedProperty\"\nenum.country.li=Liechtenstein\n# suppress inspection \"UnusedProperty\"\nenum.country.lt=Lituania\n# suppress inspection \"UnusedProperty\"\nenum.country.lu=Luxemburgo\n# suppress inspection \"UnusedProperty\"\nenum.country.mo=Macao\n# suppress inspection \"UnusedProperty\"\nenum.country.mk=Macedonia del Norte\n# suppress inspection \"UnusedProperty\"\nenum.country.mg=Madagascar\n# suppress inspection \"UnusedProperty\"\nenum.country.mw=Malaui\n# suppress inspection \"UnusedProperty\"\nenum.country.my=Malasia\n# suppress inspection \"UnusedProperty\"\nenum.country.mv=Maldivas\n# suppress inspection \"UnusedProperty\"\nenum.country.ml=Mali\n# suppress inspection \"UnusedProperty\"\nenum.country.mt=Malta\n# suppress inspection \"UnusedProperty\"\nenum.country.mh=Islas Marshall\n# suppress inspection \"UnusedProperty\"\nenum.country.mq=Martinica\n# suppress inspection \"UnusedProperty\"\nenum.country.mr=Mauritania\n# suppress inspection \"UnusedProperty\"\nenum.country.mu=Mauricio\n# suppress inspection \"UnusedProperty\"\nenum.country.yt=Mayotte\n# suppress inspection \"UnusedProperty\"\nenum.country.mx=México\n# suppress inspection \"UnusedProperty\"\nenum.country.fm=Micronesia\n# suppress inspection \"UnusedProperty\"\nenum.country.md=Moldavia\n# suppress inspection \"UnusedProperty\"\nenum.country.mc=Mónaco\n# suppress inspection \"UnusedProperty\"\nenum.country.mn=Mongolia\n# suppress inspection \"UnusedProperty\"\nenum.country.me=Montenegro\n# suppress inspection \"UnusedProperty\"\nenum.country.ms=Montserrat\n# suppress inspection \"UnusedProperty\"\nenum.country.ma=Marruecos\n# suppress inspection \"UnusedProperty\"\nenum.country.mz=Mozambique\n# suppress inspection \"UnusedProperty\"\nenum.country.mm=Myanmar (Birmania)\n# suppress inspection \"UnusedProperty\"\nenum.country.na=Namibia\n# suppress inspection \"UnusedProperty\"\nenum.country.nr=Nauru\n# suppress inspection \"UnusedProperty\"\nenum.country.np=Nepal\n# suppress inspection \"UnusedProperty\"\nenum.country.nl=Países Bajos\n# suppress inspection \"UnusedProperty\"\nenum.country.an=Antillas Neerlandesas\n# suppress inspection \"UnusedProperty\"\nenum.country.nc=Nueva Caledonia\n# suppress inspection \"UnusedProperty\"\nenum.country.nz=Nueva Zelanda\n# suppress inspection \"UnusedProperty\"\nenum.country.ni=Nicaragua\n# suppress inspection \"UnusedProperty\"\nenum.country.ne=Níger\n# suppress inspection \"UnusedProperty\"\nenum.country.ng=Nigeria\n# suppress inspection \"UnusedProperty\"\nenum.country.nu=Niue\n# suppress inspection \"UnusedProperty\"\nenum.country.nf=Isla Norfolk\n# suppress inspection \"UnusedProperty\"\nenum.country.mp=Islas Marianas del Norte\n# suppress inspection \"UnusedProperty\"\nenum.country.no=Noruega\n# suppress inspection \"UnusedProperty\"\nenum.country.om=Omán\n# suppress inspection \"UnusedProperty\"\nenum.country.pk=Pakistán\n# suppress inspection \"UnusedProperty\"\nenum.country.pw=Palaos\n# suppress inspection \"UnusedProperty\"\nenum.country.ps=Palestina\n# suppress inspection \"UnusedProperty\"\nenum.country.pa=Panamá\n# suppress inspection \"UnusedProperty\"\nenum.country.pg=Papúa Nueva Guinea\n# suppress inspection \"UnusedProperty\"\nenum.country.py=Paraguay\n# suppress inspection \"UnusedProperty\"\nenum.country.pe=Perú\n# suppress inspection \"UnusedProperty\"\nenum.country.ph=Filipinas\n# suppress inspection \"UnusedProperty\"\nenum.country.pn=Islas Pitcairn\n# suppress inspection \"UnusedProperty\"\nenum.country.pl=Polonia\n# suppress inspection \"UnusedProperty\"\nenum.country.pt=Portugal\n# suppress inspection \"UnusedProperty\"\nenum.country.pr=Puerto Rico\n# suppress inspection \"UnusedProperty\"\nenum.country.qa=Catar\n# suppress inspection \"UnusedProperty\"\nenum.country.re=Reunión\n# suppress inspection \"UnusedProperty\"\nenum.country.ro=Rumanía\n# suppress inspection \"UnusedProperty\"\nenum.country.ru=Rusia\n# suppress inspection \"UnusedProperty\"\nenum.country.rw=Ruanda\n# suppress inspection \"UnusedProperty\"\nenum.country.sh=Santa Elena, Ascensión y Tristán de Acuña\n# suppress inspection \"UnusedProperty\"\nenum.country.kn=San Cristóbal y Nieves\n# suppress inspection \"UnusedProperty\"\nenum.country.lc=Santa Lucía\n# suppress inspection \"UnusedProperty\"\nenum.country.pm=San Pedro y Miquelón\n# suppress inspection \"UnusedProperty\"\nenum.country.vc=San Vicente y las Granadinas\n# suppress inspection \"UnusedProperty\"\nenum.country.ws=Samoa\n# suppress inspection \"UnusedProperty\"\nenum.country.sm=San Marino\n# suppress inspection \"UnusedProperty\"\nenum.country.st=Santo Tomé y Príncipe\n# suppress inspection \"UnusedProperty\"\nenum.country.sa=Arabia Saudita\n# suppress inspection \"UnusedProperty\"\nenum.country.sn=Senegal\n# suppress inspection \"UnusedProperty\"\nenum.country.rs=Serbia\n# suppress inspection \"UnusedProperty\"\nenum.country.sc=Seychelles\n# suppress inspection \"UnusedProperty\"\nenum.country.sl=Sierra Leona\n# suppress inspection \"UnusedProperty\"\nenum.country.sg=Singapur\n# suppress inspection \"UnusedProperty\"\nenum.country.sk=Eslovaquia\n# suppress inspection \"UnusedProperty\"\nenum.country.si=Eslovenia\n# suppress inspection \"UnusedProperty\"\nenum.country.sb=Islas Salomón\n# suppress inspection \"UnusedProperty\"\nenum.country.so=Somalia\n# suppress inspection \"UnusedProperty\"\nenum.country.za=Sudáfrica\n# suppress inspection \"UnusedProperty\"\nenum.country.gs=Islas Georgias del Sur y Sandwich del Sur\n# suppress inspection \"UnusedProperty\"\nenum.country.ss=Sudán del Sur\n# suppress inspection \"UnusedProperty\"\nenum.country.es=España\n# suppress inspection \"UnusedProperty\"\nenum.country.lk=Sri Lanka\n# suppress inspection \"UnusedProperty\"\nenum.country.sd=Sudán\n# suppress inspection \"UnusedProperty\"\nenum.country.sr=Surinam\n# suppress inspection \"UnusedProperty\"\nenum.country.sj=Svalbard y Jan Mayen\n# suppress inspection \"UnusedProperty\"\nenum.country.sz=Esuatini\n# suppress inspection \"UnusedProperty\"\nenum.country.se=Suecia\n# suppress inspection \"UnusedProperty\"\nenum.country.ch=Suiza\n# suppress inspection \"UnusedProperty\"\nenum.country.sy=Siria\n# suppress inspection \"UnusedProperty\"\nenum.country.tw=Taiwán\n# suppress inspection \"UnusedProperty\"\nenum.country.tj=Tayikistán\n# suppress inspection \"UnusedProperty\"\nenum.country.tz=Tanzania\n# suppress inspection \"UnusedProperty\"\nenum.country.th=Tailandia\n# suppress inspection \"UnusedProperty\"\nenum.country.tl=Timor-Leste\n# suppress inspection \"UnusedProperty\"\nenum.country.tg=Togo\n# suppress inspection \"UnusedProperty\"\nenum.country.tk=Tokelau\n# suppress inspection \"UnusedProperty\"\nenum.country.to=Tonga\n# suppress inspection \"UnusedProperty\"\nenum.country.tt=Trinidad y Tobago\n# suppress inspection \"UnusedProperty\"\nenum.country.tn=Túnez\n# suppress inspection \"UnusedProperty\"\nenum.country.tr=Turquía\n# suppress inspection \"UnusedProperty\"\nenum.country.tm=Turkmenistán\n# suppress inspection \"UnusedProperty\"\nenum.country.tc=Islas Turcas y Caicos\n# suppress inspection \"UnusedProperty\"\nenum.country.tv=Tuvalu\n# suppress inspection \"UnusedProperty\"\nenum.country.ug=Uganda\n# suppress inspection \"UnusedProperty\"\nenum.country.ua=Ucrania\n# suppress inspection \"UnusedProperty\"\nenum.country.ae=Emiratos Árabes Unidos\n# suppress inspection \"UnusedProperty\"\nenum.country.gb=Reino Unido\n# suppress inspection \"UnusedProperty\"\nenum.country.us=Estados Unidos\n# suppress inspection \"UnusedProperty\"\nenum.country.um=Islas Ultramarinas Menores de Estados Unidos\n# suppress inspection \"UnusedProperty\"\nenum.country.uy=Uruguay\n# suppress inspection \"UnusedProperty\"\nenum.country.uz=Uzbekistán\n# suppress inspection \"UnusedProperty\"\nenum.country.vu=Vanuatu\n# suppress inspection \"UnusedProperty\"\nenum.country.ve=Venezuela\n# suppress inspection \"UnusedProperty\"\nenum.country.vn=Vietnam\n# suppress inspection \"UnusedProperty\"\nenum.country.vg=Islas Vírgenes Británicas\n# suppress inspection \"UnusedProperty\"\nenum.country.vi=Islas Vírgenes de los Estados Unidos\n# suppress inspection \"UnusedProperty\"\nenum.country.wf=Wallis y Futuna\n# suppress inspection \"UnusedProperty\"\nenum.country.eh=Sáhara Occidental\n# suppress inspection \"UnusedProperty\"\nenum.country.ye=Yemen\n# suppress inspection \"UnusedProperty\"\nenum.country.zm=Zambia\n# suppress inspection \"UnusedProperty\"\nenum.country.zw=Zimbabue\n# suppress inspection \"UnusedProperty\"\nenum.country.tor=Tor\n# suppress inspection \"UnusedProperty\"\nenum.country.i2p=I2P\n# suppress inspection \"UnusedProperty\"\nenum.country.lan=LAN\n"
  },
  {
    "path": "common/src/main/resources/i18n/messages_fr.properties",
    "content": "#\n# Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n\n# Common\n\nok=OK\ncancel=Annuler\nclose=Fermer\nsend=Envoyer\ncreate=Créer\nremove=Enlever\ndownload=Télécharger\nadd=Ajouter\nopen=Ouvrir\ncopy-link=Copier adresse du lien\ncopy=Copier\nsave-as=Sauver comme...\npaste-id=Coller son identifiant\nundo=Défaire\nredo=Refaire\ncut=Couper\npaste=Coller\ndelete=Effacer\nselect-all=Tout sélectionner\ndeselect-all=Désélectionner tout\nview-fullscreen=Voir en plein écran\ncopy-image=Copier image\nsave-image-as=Sauver image sous...\nenabled=Activé\nno-results=Aucun résultat trouvé\nskip=Passer\nname=Nom\nhelp=Aide\nsettings=Paramètres\nexit=Quitter\nprofile=Profil\nsubscribed=Inscrit\nown=A soi\ndescription=Description\nsubject=Sujet\nhash=Hash\nsize=Taille\ntrust=Confiance\nunknown-lc=Inconnu\nlogo=Logo\nlatest=Dernier\nupdate=Mettre à jour\nedit=Editer\nbody=Corps du texte (optionnel)\ntext=Texte\nimage=Image\nlink=Lien\nmark-read-unread=Marquer comme lu/non-lu\nthumbnail=Vignette\nposts-at-remote-nodes=Posts sur noeuds distants\nlast-activity=Dernière activité\nstate=Etat\nip=IP\nport=Port\n\n# File Requesters\n\nfile-requester.profiles=Fichiers de profiles\nfile-requester.xml=Fichiers XML\nfile-requester.png=Fichiers PNG\nfile-requester.sounds=Fichiers sonore\nfile-requester.select-sound-title=Choisissez fichier audio\nfile-requester.images=Fichiers images\nfile-requester.save-image-title=Sélectionnez où sauver votre image\nfile-requester.error=Erreur avec le fichier {0}: {1}\nfile-requester.add-files=Sélectionner fichier(s) à ajouter\n\n# Main\n\n## Menu\n\nmain.menu.add-peer=Ajouter pair...\nmain.menu.broadcast=Diffusion simultanée...\nmain.menu.shares=Configurer les partages\nmain.menu.statistics=Montrer les statistiques\n\nmain.menu.tools=Plus d'outils\nmain.menu.tools.import-from-rs=Importer des amis de Retroshare...\nmain.menu.tools.export=Exporter...\n\nmain.menu.help.about=A propos de Xeres\nmain.menu.help.documentation=Documentation\nmain.menu.help.report-bug=Signaler un bug ↗\nmain.menu.help.check-for-updates=Vérifier mises à jour... ↗\n\nmain.friends-import-successful={0} localisations ont été importées avec succès.\nmain.friends-import-errors={0} localisations ont été importées, mais {1} ont eu des erreurs.\n\nmain.systray.peers={0,number,integer} pairs connectés\n\n## Splash\n\nsplash.status.database=Chargement base de données\nsplash.status.network=Démarrage réseau\n\n## Content\n\nmain.home=Maison\nmain.contacts=Contacts\nmain.chats=Conversations\nmain.forums=Forums\nmain.files=Fichiers\nmain.boards=Boards\nmain.channels=Chaînes\n\nmain.home.slogan=Là où l'amitié rencontre la liberté\nmain.home.share-id=Ceci est votre ID Xeres. Partagez-la avec d'autres personnes.\nmain.home.received-id=Reçu l'ID d'un pair?\nmain.home.add-peer=Ajouter un pair\nmain.home.add-peer.tip=Ajouter un ami en utilisant son ID\nmain.home.need-help=Besoin d'aide?\nmain.home.online-help=Aide en ligne ↗\nmain.home.online-help.tip=Montrer l'aide en ligne (Ctrl+F1)\nmain.home.copy-id.tip=Copier l'ID Xeres dans le presse-papier\nmain.home.qrcode.tip=Utilisez le QR code pour transférer votre ID. Imprimez-le ou prenez-le en photo avec votre smartphone puis montrez-le à une webcam.\n\nmain.select-avatar=Choisissez une image d'avatar\nmain.export-profile=Sélectionnez où le profile doit être exporté\nmain.import-friends=Sélectionnez le fichier amis de Retroshare\n\nmain.scanning=Scan de {0}...\nmain.hashing=Hachage de {0}\nmain.scanning.tip=Partage: {0}, fichier: {1}\n\n## Status\n\nmain.status.connections=Connexions:\nmain.status.nat.unknown=Statut encore inconnu.\nmain.status.nat.firewalled=Le client n'est pas joignable par les connexions entrantes venant d'Internet.\nmain.status.nat.upnp=UPNP activé et client pleinement atteignable depuis Internet.\nmain.status.dht.disabled=DHT est désactivé.\nmain.status.dht.initializing=DHT est en cours d'initialisation. Ceci peut prendre un certain temps.\nmain.status.dht.running=DHT fonctionne correctement, l'adresse IP du client est distribué à ses pairs.\nmain.status.dht.stats=Nombre de pairs: {0,number,integer}\\nPaquets reçus: {1,number,integer} ({2})\\nPaquets envoyés: {3,number,integer} ({4})\\nNombre de clefs: {5,number,integer}\\nNombre de valeurs: {6,number,integer}\n\nmain.exit.confirm=Etes-vous sûr de vouloir quitter Xeres?\n\n# Account creation\n\naccount.welcome=Bienvenue dans Xeres\naccount.welcome.tip=Vous devez créer un profile et une localisation.\\n\\nLe profile représente vous-même, vous pouvez utiliser votre nom ou surnom, quant à la localisation, il s'agit de la machine que vous utilisez actuellement.\\n\\nVous pouvez disposer de plusieurs localisations comme une machine desktop et un laptop qui utilisent le même profile (vous).\\n\\nL'option d'importation permet d'utiliser un profile crée précédemment.\\n\\nTout est toujours stocké en local donc n'oubliez pas de faire des sauvegardes.\\n\\nAppuyez sur la touche F1 pour lire la documentation intégrée, et souvenez-vous qu'en tout temps, vous pouvez laisser votre pointeur de souris pendant un instant sur un objet pour avoir droit à une aide contextuelle.\naccount.profile.prompt=Nom du profil\naccount.profile.tip=Utilisez un surnom ou votre vrai nom. Un profil peut avoir plusieurs localisations.\naccount.location=Localisation\naccount.location.prompt=Nom de localisation\naccount.location.tip=Ceci est votre instance Xeres sur cet appareil. Utilisez le nom de votre appareil ou le model.\naccount.options=Options\naccount.generation.profile-keys=Génération des clefs de profile...\naccount.generation.location-keys-and-certificate=Génération des clefs de localisation et du certificat...\naccount.generation.identity=Génération de l'identité...\naccount.generation.profile-load=Sélectionner un fichier de profile Xeres (xeres_backup.xml), un fichier Keyring de Retroshare (retroshare_secret_keyring.gpg) ou un profile Retroshare (*.asc)\naccount.generation.import=Importer...\naccount.generation.import.tip=Vous pouvez importer 3 types de profiles:\\n\\nUn profile exporté depuis Xeres (xeres_backup.xml).\\n\\nUn keyring Retroshare (retroshare_secret_keyring.gpg) ou un profile exporté depuis Retroshare (*.asc).\naccount.generation.import.progress=Importation du profile...\naccount.generation.import.confirm.title=Importeur Retroshare\naccount.generation.import.confirm.prompt=Entrez le mot de passe de Retroshare\naccount.generation.import.unknown=Format de fichier inconnu\n\n# Chat\n\n## Common\n\nchat.notification.typing={0} est en train d''écrire\n\n## Room common\n\nchat.room.id=Identifiant\nchat.room.topic=Topic\nchat.room.security=Securité\nchat.room.users=Utilisateurs\n\nchat.room.info=Sujet: {0}\\nUtilisateurs: {1,number,integer}\\nSécurité: {2}\\nIdentifiant: {3}\nchat.room.none=[aucun]\nchat.room.private=privé\nchat.room.public=public\nchat.room.signed-only=identifiants signés uniquement\nchat.room.anonymous-allowed=identifiants anonymes autorisés\nchat.room.user-info=Nom: {0}\\nIdentifiant: {1}\nchat.room.user-menu=Information\nchat.room.clear-history=Voulez-vous vraiment effacer l'historique?\nchat.room.copy-selection=Copier la sélection\nchat.room.clear-chat-history=Effacer l'historique de chat\n\n## Room create\n\nchat.room.create.window-title=Création de salon\nchat.room.create.name.prompt=Nom court et descriptif du salon\nchat.room.create.name.tip=Nom de la salle. Utilisez les majuscules et les espaces appropriés.\nchat.room.create.topic.prompt=But du salon\nchat.room.create.topic.tip=La description du salon, de quoi on y parle.\nchat.room.create.visibility=Visibilité\nchat.room.create.visibility.tip=Les salons publics sont visibles par les pairs.\\nLes salons privés sont joignables sur invitation uniquement.\nchat.room.create.security.checkbox=Identités signées uniquement\nchat.room.create.security.tip=Un salon limité uniquement aux identités signées est plus résistant contre le spam, car les identités anonymes ne peuvent le joindre.\nchat.room.create.tooltip=Créer un nouveau salon\n\n## Room invite\n\nchat.room.invite.window-title=Invitation de pair dans le salon actuel\nchat.room.invite.button=Inviter\nchat.room.invite.tip=Inviter un peer dans le salon courant\nchat.room.invite.request={0} veut vous inviter dans {1} ({2})\n\nchat.room.join=Joindre\nchat.room.leave=Partir\n\nchat.room.not-found=Salon introuvable. Le salon est probablement absent de la liste de vos amis.\n\n# Forums\n\ngxs-group.tree.popular=Populaire\ngxs-group.tree.other=Autre\n\ngxs-group.tree.info=Nom: {0}\\nID: {1}\\nMessages distants: {2}\\nActivité distante: {3}\n\ngxs-group.tree.subscribe=S'inscrire\ngxs-group.tree.unsubscribe=Se désinscrire\n\nforum.new-message.window-title=Nouveau message\nforum.create.window-title=Créer forum\nforum.create.name.prompt=Nom court et descriptif du forum\nforum.create.name.tip=Nom du forum. Utiliser une capitalisation et des espaces appropriés.\nforum.create.description.prompt=A quoi le forum sert\nforum.create.description.tip=La description du forum, à quoi il sert.\n\nforum.editor.name=Forum\nforum.editor.name.prompt=Le nom du forum\nforum.editor.thread.description=Le sujet du thread\nforum.editor.cancel=Le message pour le forum n'a pas encore été envoyé! Etes-vous sûr de vouloir effacer ce message?\n\nforum.view.create.tip=Créer un nouveau forum\nforum.view.header.author=Auteur\nforum.view.header.date=Date\n\nforum.view.new-message.tip=Créer un nouveau message\n\nforum.view.group.not-found=Forum introuvable. Il n'est probablement pas disponible chez vos amis.\nforum.view.message.not-found=Message introuvable. Il est probablement trop ancien ou son auteur n'a pas assez bonne réputation.\n\nforum.view.from=De:\nforum.view.subject=Sujet:\nforum.view.reply=Répondre\n\nforum.view.history=Ce sélecteur permet d'afficher les versions précédentes du message.\n\n# Boards\n\nboard.create.window-title=Créer board\nboard.create.name.prompt=Nom court et descriptif du board\nboard.create.name.tip=Nom du board. Utiliser une capitalisation et des espaces appropriés.\nboard.create.description.prompt=A quoi le board sert\nboard.create.description.tip=La description du board, à quoi il sert.\nboard.select-logo=Sélectionnez une image pour le board\nboard.select-image=Sélectionnez une image pour le message\n\nboard.view.create.tip=Créer un nouveau board\nboard.view.group.not-found=Board introuvable. Il n'est probablement pas disponible chez vos amis.\n\nboard.new-message.window-title=Nouveau message pour le board\n\nboard.editor.name=Board\nboard.editor.name.prompt=Le nom du board\nboard.editor.thread.title=Titre\nboard.editor.post.description=Le titre du message\n\nboard.editor.cancel=Le message pour le board n'a pas encore été envoyé! Etes-vous sûr de vouloir effacer ce message?\n\nboard.posted-by=Envoyé par\nboard.on=le\n\n# Channels\n\nchannel.view.create.tip=Créer une nouvelle chaîne\nchannel.create.window-title=Créer chaîne\nchannel.create.name.prompt=Nom court et descriptif de la chaîne\nchannel.create.name.tip=Nom de la chaîne. Utiliser une capitalisation et des espaces appropriés.\nchannel.create.description.prompt=A quoi la chaîne sert\nchannel.create.description.tip=La description de la chaîne, à quoi elle sert.\nchannel.select-image=Sélectionnez une image pour le message\n\nchannel.view.group.not-found=Chaîne introuvable. Elle n'est probablement pas disponible chez vos amis.\n\nchannel.select-logo=Sélectionnez une image pour la chaîne\n\nchannel.new-message.window-title=Nouveau message pour la chaîne\n\nchannel.editor.name=Chaîne\nchannel.editor.name.prompt=Le nom de la chaîne\nchannel.editor.thread.title=Titre\nchannel.editor.post.description=Le titre du message\n\nchannel.editor.cancel=Le message pour la chaîne n'a pas encore été envoyé! Etes-vous sûr de vouloir effacer ce message?\nchannel.clipboard.error=Le presse papier ne contient aucun lien de fichiers.\nchannel.files=Fichiers\nchannel.post=Message\nchannel.drag-drop=Ajouter fichier ou faire un drag and drop ici\nchannel.add-files=Ajouter fichier(s)\nchannel.paste-links=Coller liens(s)\nchannel.remove-files=Enlever fichier(s)\n\n# Add RSID\n\nrs-id.add.window-title=Ajout de pair\nrs-id.add.textarea.prompt=Collez l'identifiant du pair\nrs-id.add.textarea.tip=L'identifiant est une chaîne d'une centaine de charactères en base 64. Elle contient toutes les informations nécessaires pour se connecter à un pair.\nrs-id.add.details=Détails du pair\nrs-id.add.name.tip=Nom du pair, assurez-vous qu'il s'agit bien de la bonne personne.\nrs-id.add.profile=Identifiant du profil\nrs-id.add.profile.tip=Identifiant unique pour vérifier si le profil du pair est le bon.\nrs-id.add.fingerprint=Empreinte\nrs-id.add.fingerprint.tip=Chaîne de contrôle cryptographique pour certifier l'authenticité du profil du pair.\nrs-id.add.location=Identifiant de localisation\nrs-id.add.location.tip=Identifiant de localisation. Un profil peut avoir plusieurs localisations dont chacune possède un identifiant unique.\nrs-id.add.addresses=Adresses\nrs-id.add.addresses.tip=Adresses pour s'y connecter. Elles seront essayées une à une dans l'ordre, mais vous pouvez préselectionner la meilleure afin d'accélérer la connexion.\\nLes adresses qui se terminent en .onion nécessitent un proxy Tor.\\nLes adresses qui se terminent en .i2p nécessitent un proxy I2P.\nrs-id.add.trust.tip=Le niveau de confiance dans le pair.\\nInconnu: pas d'opinion.\\nJamais: aucune ou minimale, rencontré en ligne récemment.\\nMarginal: plus ou moins en confiance, connaissance.\\nTotal: très confiant, ami.\nrs-id.add.invalid=Identifiant invalide\nrs-id.add.scan=Scannez le code QR avec la caméra.\n\n# Broadcast\n\nbroadcast.window-title=Envoi multiple\nbroadcast.send.explanation=Envoye un message\nbroadcast.send.warning-header=Attention:\nbroadcast.send.warning=n'abusez pas de cette fonction. Utilisez-la uniquement\n\n# Messaging\n\nmessaging.prompt=Ecrivez un message\nmessaging.file-requester.send-picture=Selectionnez une image à envoyer dans le chat\nmessaging.file-requester.send-file=Sélectionnez un fichier à transmettre\nmessaging.send-picture=Sélectionnez une image pour envoyer dans le chat\nmessaging.send-sticker=Envoyer un sticker\nmessaging.send.file=Sélectionnez un fichier à envoyer\nmessaging.action.call=Passer un appel direct\nmessaging.action.send-inline=Envoyer une image en ligne\nmessaging.action.send-file=Envoyer un fichier\n\nmessaging.warning.title=Attention\nmessaging.warning.description=L'utilisateur est actuellement déconnecté et ne peut recevoir de messages.\n\nmessaging.tunneling=Tentative d'établissement d'un tunnel...\n\nmessaging.closing-tunnel.confirm=Fermer cette fenêtre arrêtera la conversation distante et supprimera tout message en cours d'envoi. Etes-vous sûr?\n\n# Profiles\n\nprofiles.delete=Effacer profil\n\n# About\n\nabout.window-title=A propos de {0}\nabout.version=Version:\nabout.title=A propos\nabout.slogan=Une application de connexion ami-à-ami, décentralisée et sécurisée pour la communication et le partage\nabout.authors=Auteurs\nabout.author-by=par\nabout.all-rights-reserved=Tous droits réservés\nabout.report-bugs=Rapport de bugs ou suggestions.\nabout.website=Site web\nabout.wiki=Wiki\nabout.source-code=Code source\nabout.thanks=Remerciements\nabout.license=Licence\nabout.additional-licenses=Licenses additionnelles\nabout.release=Production\nabout.profiles=Profils:\n\n# QR Code\n\nqr-code.window-title=QR code\nqr-code.print=Imprimer...\nqr-code.save-as-png=Sauver en PNG\nqr-code.download-client=Télécharger le client sur https://xeres.io\nqr-code.camera.error=Caméra non détectée\n\n# Camera\n\ncamera.window-title=Scanner un QR code\n\n# Settings\n\n## Main\n\nsettings.general=Général\nsettings.network=Réseau\nsettings.transfer=Transfers\nsettings.notifications=Notifications\nsettings.sound=Son\nsettings.remote=Accès distant\nsettings.directory.no-remote=Impossible de choisir un répertoire en mode d'accès distant\n\n## General\n\nsettings.general.theme=Thème\nsettings.general.system=Système\nsettings.general.startup=Lancer au démarrage du système\nsettings.general.startup.tip=Se lance automatiquement au démarrage du système, minimisé dans le tray.\nsettings.general.startup.not-available=Non disponible. Soit l'OS n'est pas supporté, soit le programme tourne en mode portable.\nsettings.general.update-check=Vérifier les mises à jour automatiquement\nsettings.general.update-check.tip=Vérifie GitHub une fois par jour pour détecter une nouvelle version.\n\n## Network\n\nsettings.network.hidden-services=Services anonymes\nsettings.network.tor-proxy=Proxy Socks Tor\nsettings.network.tor-proxy.prompt=Serveur Tor\nsettings.network.tor-proxy.tip=L'adresse IP ou le nom d'hôte du Tor SOCKS v5, habituellement 127.0.0.1 s'il tourne sur la même machine.\nsettings.network.tor-port.tip=Le port du Tor SOCKS v5, normalement 9050.\nsettings.network.i2p-proxy=Proxy Socks I2P\nsettings.network.i2p-proxy.prompt=Serveur I2P\nsettings.network.i2p-proxy.tip=L'adresse IP ou le nom d'hôte du I2P SOCKS v5, habituellement 127.0.0.1 s'il tourne sur la même machine.\nsettings.network.i2p-port.tip=Le port du I2P SOCKS v5, normalement 4447.\nsettings.network.use-upnp=Utiliser UPNP\nsettings.network.use-upnp.tip=L'UPNP (Plug & Play Universel) vous permet de configurer les ports entrants de votre routeur automatiquement. Cela améliore la robustesse des connexions de vos pairs.\nsettings.network.external-ip-and-port=IP externe et port\nsettings.network.external-ip-and-port.tip=L'adresse IP et le port externe de votre localisation. Ceci est comment vous apparaissez sur Internet.\nsettings.network.use-broadcast-discovery=Utiliser Broadcast Discovery\nsettings.network.use-broadcast-discovery.tip=Le Broadcast Discovery permet d'annoncer votre IP et votre port aux autres localisations sur le LAN. Ceci améliore la robustesse des connexions avec d'éventuels pairs sur le LAN.\nsettings.network.internal-ip-and-port=IP internet et port\nsettings.network.internal-ip-and-port.tip=L'adresse IP et le port interne de votre localisation. Ceci est comment vous apparaissez sur votre LAN (réseau local).\nsettings.network.use-dht=Utiliser DHT\nsettings.network.use-dht.tip=Le DHT (table de hachage distribuée) permet aux pairs de se trouver leur adresse IP mutuellement. Ceci améliore la connectivité lors des déplacements.\n\n## Remote\n\nsettings.remote.title=Accès distant\nsettings.remote.username=Nom d'utilisateur\nsettings.remote.password=Mot de passe\nsettings.remote.note=Un mot de passe vide désactive l'authentification.\nsettings.remote.enabled.tip=Active l'accès à distance. Cette instance peut ensuite être accédée depuis une autre instance de Xeres ou le client mobile Android.\nsettings.remote.upnp-set=Avec UPNP\nsettings.remote.upnp-set.tip=Configure le port distant avec UPNP, ce qui le rend accessible depuis Internet.\nsettings.remote.restart=Vous devez redémarrer Xeres pour que les changements d'accès distant soient effectifs. Quitter maintenant?\nsettings.remote.view-api=Consultez l'API\n\n## Transfer\n\nsettings.transfer.select-incoming=Sélectionnez le répertoire d'arrivée\nsettings.transfer.incoming=Répertoire entrant\n\n## Notifications\n\nsettings.notifications.show-connections=Montrer les connexions\nsettings.notifications.show-connections.tip=Montre quand une connexion avec un ami a lieu.\nsettings.notifications.show-broadcasts=Montrer les diffusions simultanées\nsettings.notifications.show-broadcasts.tip=Montrer les diffusions simultanées envoyées par vos amis.\nsettings.notifications.show-discovery=Montrer les discovery\nsettings.notifications.show-discovery.tip=Montre quand un client qui envoi des Broadcast Discovery est sur le LAN.\n\n## Sound\n\nsettings.sound.message=Message reçu\nsettings.sound.message.tip=Joue un son quand un message privé est recu et que la fenêtre est inactive.\nsettings.sound.highlight=Mise en évidence\nsettings.sound.highlight.tip=Joue un son quand quelqu'un vous écrit dans un salon.\nsettings.sound.friend=Ami connecté\nsettings.sound.friend.tip=Joue un son quand un ami se connecte à vous.\nsettings.sound.download=Téléchargement terminé\nsettings.sound.download.tip=Joue un son quand un téléchargement se termine.\nsettings.sound.ringing=Appel\nsettings.sound.ringing.tip=Joue un son lors de la réception ou de l'émission d'un appel.\n\n# Share\n\nshare.window-title=Partages\nshare.select-directory=Sélectionnez le répertoire à partager\nshare.remove=Supprimer le partage\nshare.error.empty-name=Le nom de partage ne peut être vide. Choisissez un nom unique.\nshare.error.empty-path=Le chemin de partage ne peut être vide. Choisissez un chemin de partage.\nshare.error.not-unique=Ce nom de partage existe déjà. Chaque nom de partage doit être unique.\n\nshare.list.directory=Répertoire partagé\nshare.list.visible-name=Nom visible\nshare.list.searchable=Recherchable\nshare.list.browsable=Navigable\n\nshare.create=Créer un nouveau partage\nshare.apply=Appliquer et fermer\n\n# Tray\n\ntray.open=Ouvrir {0}\ntray.peers=Pairs\ntray.status=Status\n\n# EditorView\n\neditor.hyperlink.enter=Entrer URL\neditor.action.undo=Défaire (Ctrl+Z)\neditor.action.redo=Refaire (Ctrl+Shift+Z)\neditor.action.bold=Gras (Ctrl+B)\neditor.action.italic=Italique (Ctrl+I)\neditor.action.hyperlink=Hyperlien (Ctrl+L)\neditor.action.quote=Citation (Ctrl+Q)\neditor.action.code=Code (Ctrl+K)\neditor.action.unordered-list=Liste (Ctrl+U)\neditor.action.ordered-list=Liste ordonnée (Ctrl+Shift+U)\neditor.action.header=Entête (Ctrl+1)\neditor.action.preview=Prévisualiser le message (F12)\n\n# Search / Download / Uploads\n\nsearch.main.search=Recherche\nsearch.main.downloads=Downloads\nsearch.main.uploads=Uploads\nsearch.main.trends=Tendances\n\nsearch.input.prompt=Entrez les termes de recherche\nsearch.input.search.tip=Saisir plusieurs termes cherchera les fichiers qui les contiennent tous. Utilisez \" autours des termes pour une recherche exacte.\nsearch.searching=Recherche en cours...\n\ntrends.none=Aucune tendance pour le moment\ntrends.list.terms=Termes\ntrends.list.from=De\ntrends.list.time=Heure\n\ndownload-view.list.none=Pas de downloads\ndownload-view.list.state=Etat\ndownload-view.list.progress=Progression\ndownload-view.list.total-size=Taille totale\n\ndownload-view.show-in-folder=Montrer dans le répertoire\n\ndownload-view.open-error=Impossible d'ouvrir:\ndownload-view.show-error=Impossible d'ouvrir dans l'explorateur:\n\ndownload-add.window-title=Télécharger\ndownload-add.bytes={0,number,integer} octets\n\nupload-view.none=Aucun fichier en cours d'upload\n\nfile-result.column.type=Type\n\n# StatisticsTurtle\n\nstatistics.window-title=Statistiques\n\nstatistics.elapsed-time=Temps écoulé (secondes)\n\nstatistics.turtle.data-in=Données entrantes\nstatistics.turtle.data-in.tip=Le contenu de données reçu (downloads)\nstatistics.turtle.data-out=Données sortantes\nstatistics.turtle.data-out.tip=Le contenu de données envoyées (uploads)\nstatistics.turtle.data-forward=Données réenvoyées\nstatistics.turtle.data-forward.tip=Le contenu de données réenvoyées à d'autres pairs\nstatistics.turtle.tunnel-in=Requêtes tunnels entrantes\nstatistics.turtle.tunnel-in.tip=Requêtes de tunnels entrantes\nstatistics.turtle.tunnel-out=Requêtes tunnels sortantes\nstatistics.turtle.tunnel-out.tip=Requêtes de tunnels réenvoyées et nos propres requêtes\nstatistics.turtle.search-in=Requêtes de recherches entrantes\nstatistics.turtle.search-in.tip=Requêtes de recherche entrantes\nstatistics.turtle.search-out=Requêtes de recherches sortantes\nstatistics.turtle.search-out.tip=Requêtes de recherche sortantes et nos propres requêtes\nstatistics.turtle.bandwidth=Bande passante\nstatistics.turtle.speed=Vitesse (KB/s)\nstatistics.turtle.tip=Le graphique affiche les statistiques du routeur turtle. Il consiste en\\nRequêtes de recherche: recherche de fichiers (titre, taille, etc...)\\nRequêtes de tunnels: mise en place des tunnels entre les pairs distants pour préparer un transfert de fichiers.\\nRequêtes de données: flux de donnée entre les tunnels.\\nLa plupart des requêtes de données qui ne nous sont pas destinées sont réenvoyées à d'autres pairs, avec une probabilité variante suivant la distance.\n\nstatistics.rtt.rtt=Temps de trajet aller-retour\nstatistics.rtt.time=RTT (millisecondes)\nstatistics.rtt.tip=Le RTT (Round Trip Time ou temps de trajet aller-retour), est le temps qu'il faut pour un message pour arriver à destination ainsi que le temps qu'il fout pour la réponse pour revenir à l'envoyeur. Ceci donne une idée de la latence réseau entre les pairs. Il peut y avoir des problèmes si le RTT est trop élevé (au-delà de quelques secondes).\n\nstatistics.data-counter.title=Utilisation des données\nstatistics.data-counter.data=Données (KB)\nstatistics.data-counter.tip=Ce graphique montre les données qui vont et viennent des pairs.\nstatistics.data-counter.peers=Pairs\n\nstatistics.turtle=Turtle\nstatistics.rtt=RTT\nstatistics.data-usage=Utilisation données\n\n# ContactView\n\ncontact-view.profile-delete.confirm=Ceci va supprimer et déconnecter le profile {0}. Voulez-vous vraiment continuer?\ncontact-view.avatar-delete.confirm=Voulez-vous vraiment enlever votre image d'avatar?\ncontact-view.location.last-connected.now=Maintenant\ncontact-view.location.last-connected.never=Jamais\ncontact-view.information.linked-to-profile=Identité reliée au profile\ncontact-view.information.profile=Profile\ncontact-view.information.identity=Identité\ncontact-view.information.type=Type\ncontact-view.information.created=Crée\ncontact-view.information.updated=Mis à jour\ncontact-view.information.created-unknown=inconnu\ncontact-view.information.key-information-with-length=Version: {0}\\nAlgorithme: {1}\\nTaille: {2} bits\\nHash de signature: {3}\ncontact-view.information.key-information=Version: {0}\\nAlgorithme: {1}\\nHash de signature: {2}\ncontact-view.open.identity-not-found=Identité non trouvée\ncontact-view.open.profile-not-found=Profile non trouvé\ncontact-view.information.location.id=ID de localisation:\ncontact-view.information.location.version=Version:\ncontact-view.search.prompt=Chercher personne\ncontact-view.search.show-all=Montrer tous les contacts\ncontact-view.search.no-contacts=Pas de contacts\ncontact-view.badge.own=Soi-même\ncontact-view.badge.own.tip=Ceci est vous-même.\ncontact-view.badge.partial=Partiel\ncontact-view.badge.partial.tip=Un contact partiel n'est pas encore représenté par un profile complet. Une connexion avec lui doit s'effectuer au moins une fois pour qu'il soit contrôlé, et, si cette opération se déroule avec succès, il sera promu en profile complet.\ncontact-view.badge.accepted=Accepté\ncontact-view.badge.accepted.tip=Ce contact est accepté pour les connexions entrantes. Les connexions sortantes seront tentées également.\ncontact-view.badge.not-validated=Pas encore validé\ncontact-view.badge.not-validated.tip=Ce contact n'a pas encore été validé. Sa signature de profile sera verifiée bientôt et, si effectuée avec succès, il sera marqué comme valid. En cas d'échec, il sera effacé (mais il sera peut-être transferré de nouveau, dans ce cas, tentez d'en informer son propriétaire).\ncontact-view.action.chat=Converser\ncontact-view.action.distant-chat=Converser à distance\ncontact-view.action.connect=Tenter de se connecter\ncontact-view.information.locations=Localisations\ncontact-view.column.last-connected=Dernière connexion\ncontact-view.chat.start=Démarrer une conversation en connexion directe\ncontact-view.distant-chat.start=Démarrer une conversation à distance\n\n# ImageSelectorView\n\nimage-selector-view.change-image=Changer image...\nimage-selector-view.change-image-short=Changer\nimage-selector-view.add-image=Ajouter image...\n\n# VoIP\n\nvoip.window-title=Appel\nvoip.action.message=Message\nvoip.action.message.tip=Envoyer un message direct à l'utilisateur\nvoip.action.recall=Rappeler\nvoip.action.recall.tip=Rappeler l'utilisateur\nvoip.action.close.tip=Fermer la fenêtre\nvoip.action.answer=Répondre\nvoip.action.reject=Rejeter\nvoip.action.hangup=Raccrocher\nvoip.action.window-quit=Etes-vous sûr de vouloir abandonner l'appel?\nvoip.status.incoming=Appel entrant...\nvoip.status.calling=Appel...\nvoip.status.ongoing=En appel\nvoip.status.ended=Fin de l'appel\n\n# Update\n\nupdate.latest-already=Vous avez déjà la dernière version.\nupdate.new-version=Il y a une nouvelle version de disponible ({0}). Télécharger, vérifier et installer?\nupdate.new-version-auto=Il y a une nouvelle version de disponible ({0}).\nupdate.download-failure=Impossible de télécharger l'url et/ou l'url de vérification\nupdate.download-file=Téléchargement du fichier...\nupdate.download.title=Mise à jour Xeres\nupdate.download.verifying=Vérification du fichier...\nupdate.download.install=Installer\nupdate.download.install-ready=Prêt à installer!\nupdate.download-verification-failed=Echec de vérification!\n\n# Stickers\n\nstickers.instructions=Ajoutez vos stickers dans {0}\\n\\nUn répertoire par collection de stickers, chacun contenant des PNGs ou des JPEGs.\n\n# ChatCommands\n\nchat-command.code=Envoi le texte comme du code\nchat-command.coin=Pile ou face\nchat-command.me=Envoi une action à la troisième personne\nchat-command.pre=Envoi le texte préformaté\nchat-command.quote=Envoi le texte comme citation\nchat-command.random=Envoi un nombre aléatoire de 1 à 10\nchat-command-send=Envoi {0}\n\n# Misc\n\nuri.malicious-link=Attention! Ce lien est malicieux, il vous emmènera sur: {0}\nuri.unsafe-link=Attention! Ce lien est peut-être malicieux, il vous emmènera sur: {0}\nuri.malicious-link.confirm=Attention! Ceci est un lien malicieux qui va vous emmener sur {0}. Voulez-vous continuer?\nuri.unsafe-link.confirm=Attention! Ce lien est peut-être malicieux, il va vous emmener sur {0}. Savez-vous ce que c'est et êtes vous confiant du résultat?\n\ncontent-image.exit=Appuyez sur ESC ou cliquez pour sortir\n\nwebsocket.disconnected=Connexion WebSocket perdue. Chat indisponible. Reconnexion?\n\n# TrustConverter\n\ntrust-converter.nobody=Personne\ntrust-converter.everybody=Tout le monde\ntrust-converter.marginal=Confiance marginale\ntrust-converter.full=Confiance totale\ntrust-converter.ultimate=Uniquement moi\n\n# Byte units\n\nbyte-unit.invalid=invalide\nbyte-unit.bytes=octets\nbyte-unit.kb=Ko\nbyte-unit.mb=Mo\nbyte-unit.gb=Go\nbyte-unit.tb=To\nbyte-unit.pb=Po\nbyte-unit.eb=Eo\n\n# Help\n\nhelp.back=Revenir dans l'historique\nhelp.forward=Avancer dans l'historique\nhelp.home=Aller dans la section principale\n\n# Enums (beware of the key naming which must be the same as the class!)\n\n## Trust\n\n# suppress inspection \"UnusedProperty\"\nenum.trust.unknown=Inconnu\n# suppress inspection \"UnusedProperty\"\nenum.trust.never=Jamais\n# suppress inspection \"UnusedProperty\"\nenum.trust.marginal=Marginal\n# suppress inspection \"UnusedProperty\"\nenum.trust.full=Total\n# suppress inspection \"UnusedProperty\"\nenum.trust.ultimate=Ultime\n\n## Availability\n\n# suppress inspection \"UnusedProperty\"\nenum.availability.available=Disponible\n# suppress inspection \"UnusedProperty\"\nenum.availability.busy=Occupé\n# suppress inspection \"UnusedProperty\"\nenum.availability.away=Absent\n# suppress inspection \"UnusedProperty\"\nenum.availability.offline=Déconnecté\n\n## RoomType\n\n# suppress inspection \"UnusedProperty\"\nenum.room-type.private=Privé\n# suppress inspection \"UnusedProperty\"\nenum.room-type.public=Public\n\n## FileType\n\n# suppress inspection \"UnusedProperty\"\nenum.file-type.any=Tous\n# suppress inspection \"UnusedProperty\"\nenum.file-type.audio=Audio\n# suppress inspection \"UnusedProperty\"\nenum.file-type.archive=Archive\n# suppress inspection \"UnusedProperty\"\nenum.file-type.document=Document\n# suppress inspection \"UnusedProperty\"\nenum.file-type.picture=Image\n# suppress inspection \"UnusedProperty\"\nenum.file-type.program=Programme\n# suppress inspection \"UnusedProperty\"\nenum.file-type.video=Vidéo\n# suppress inspection \"UnusedProperty\"\nenum.file-type.subtitles=Sous-titre\n# suppress inspection \"UnusedProperty\"\nenum.file-type.collection=Collection\n# suppress inspection \"UnusedProperty\"\nenum.file-type.directory=Répertoire\n\n## FileProgressDisplay State\n\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.searching=Recherche\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.transferring=Transfert\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.removing=Retirement\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.done=Terminé\n\n## FileAttachment State\n\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.hashing=Hachage\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.done=Terminé\n\n## Country\n\n# suppress inspection \"UnusedProperty\"\nenum.country.af=Afghanistan\n# suppress inspection \"UnusedProperty\"\nenum.country.al=Albanie\n# suppress inspection \"UnusedProperty\"\nenum.country.dz=Algérie\n# suppress inspection \"UnusedProperty\"\nenum.country.as=Samoa américaines\n# suppress inspection \"UnusedProperty\"\nenum.country.ad=Andorre\n# suppress inspection \"UnusedProperty\"\nenum.country.ao=Angola\n# suppress inspection \"UnusedProperty\"\nenum.country.ai=Anguille\n# suppress inspection \"UnusedProperty\"\nenum.country.aq=Antarctique\n# suppress inspection \"UnusedProperty\"\nenum.country.ag=Antigua-et-Barbuda\n# suppress inspection \"UnusedProperty\"\nenum.country.ar=Argentine\n# suppress inspection \"UnusedProperty\"\nenum.country.am=Arménie\n# suppress inspection \"UnusedProperty\"\nenum.country.aw=Aruba\n# suppress inspection \"UnusedProperty\"\nenum.country.au=Australie\n# suppress inspection \"UnusedProperty\"\nenum.country.at=Autriche\n# suppress inspection \"UnusedProperty\"\nenum.country.az=Azerbaïdjan\n# suppress inspection \"UnusedProperty\"\nenum.country.ba=Bosnie Herzégovine\n# suppress inspection \"UnusedProperty\"\nenum.country.bs=Bahamas\n# suppress inspection \"UnusedProperty\"\nenum.country.bh=Bahreïn\n# suppress inspection \"UnusedProperty\"\nenum.country.bd=Bangladesh\n# suppress inspection \"UnusedProperty\"\nenum.country.bb=Barbade\n# suppress inspection \"UnusedProperty\"\nenum.country.by=Biélorussie\n# suppress inspection \"UnusedProperty\"\nenum.country.be=Belgique\n# suppress inspection \"UnusedProperty\"\nenum.country.bz=Bélize\n# suppress inspection \"UnusedProperty\"\nenum.country.bj=Bénin\n# suppress inspection \"UnusedProperty\"\nenum.country.bm=Bermudes\n# suppress inspection \"UnusedProperty\"\nenum.country.bt=Bhoutan\n# suppress inspection \"UnusedProperty\"\nenum.country.bo=Bolivie\n# suppress inspection \"UnusedProperty\"\nenum.country.bw=Botswana\n# suppress inspection \"UnusedProperty\"\nenum.country.bv=Île Bouvet\n# suppress inspection \"UnusedProperty\"\nenum.country.br=Brésil\n# suppress inspection \"UnusedProperty\"\nenum.country.io=Territoire britannique de l'océan Indien\n# suppress inspection \"UnusedProperty\"\nenum.country.bn=Brunéi\n# suppress inspection \"UnusedProperty\"\nenum.country.bg=Bulgarie\n# suppress inspection \"UnusedProperty\"\nenum.country.bf=Burkina Faso\n# suppress inspection \"UnusedProperty\"\nenum.country.bi=Burundi\n# suppress inspection \"UnusedProperty\"\nenum.country.kh=Cambodge\n# suppress inspection \"UnusedProperty\"\nenum.country.cm=Cameroun\n# suppress inspection \"UnusedProperty\"\nenum.country.ca=Canada\n# suppress inspection \"UnusedProperty\"\nenum.country.cv=Cap-Vert\n# suppress inspection \"UnusedProperty\"\nenum.country.ky=Îles Caïmans\n# suppress inspection \"UnusedProperty\"\nenum.country.cf=République centrafricaine\n# suppress inspection \"UnusedProperty\"\nenum.country.td=Tchad\n# suppress inspection \"UnusedProperty\"\nenum.country.cl=Chili\n# suppress inspection \"UnusedProperty\"\nenum.country.cn=Chine\n# suppress inspection \"UnusedProperty\"\nenum.country.cx=L'île de noël\n# suppress inspection \"UnusedProperty\"\nenum.country.cc=Îles Cocos (Keeling)\n# suppress inspection \"UnusedProperty\"\nenum.country.co=Colombie\n# suppress inspection \"UnusedProperty\"\nenum.country.km=Comores\n# suppress inspection \"UnusedProperty\"\nenum.country.cg=Congo\n# suppress inspection \"UnusedProperty\"\nenum.country.cd=République démocratique du Congo\n# suppress inspection \"UnusedProperty\"\nenum.country.ck=Les Îles Cook\n# suppress inspection \"UnusedProperty\"\nenum.country.cr=Costa Rica\n# suppress inspection \"UnusedProperty\"\nenum.country.ci=Côte d'Ivoire\n# suppress inspection \"UnusedProperty\"\nenum.country.hr=Croatie\n# suppress inspection \"UnusedProperty\"\nenum.country.cu=Cuba\n# suppress inspection \"UnusedProperty\"\nenum.country.cy=Chypre\n# suppress inspection \"UnusedProperty\"\nenum.country.cz=République tchèque\n# suppress inspection \"UnusedProperty\"\nenum.country.dk=Danemark\n# suppress inspection \"UnusedProperty\"\nenum.country.dj=Djibouti\n# suppress inspection \"UnusedProperty\"\nenum.country.dm=Dominique\n# suppress inspection \"UnusedProperty\"\nenum.country.do=République Dominicaine\n# suppress inspection \"UnusedProperty\"\nenum.country.ec=Equateur\n# suppress inspection \"UnusedProperty\"\nenum.country.eg=Egypte\n# suppress inspection \"UnusedProperty\"\nenum.country.sv=Le Salvador\n# suppress inspection \"UnusedProperty\"\nenum.country.gq=Guinée Équatoriale\n# suppress inspection \"UnusedProperty\"\nenum.country.er=Érythrée\n# suppress inspection \"UnusedProperty\"\nenum.country.ee=Estonie\n# suppress inspection \"UnusedProperty\"\nenum.country.et=Ethiopie\n# suppress inspection \"UnusedProperty\"\nenum.country.fk=Îles Falkland (Malouines)\n# suppress inspection \"UnusedProperty\"\nenum.country.fo=Îles Féroé\n# suppress inspection \"UnusedProperty\"\nenum.country.fj=Fidji\n# suppress inspection \"UnusedProperty\"\nenum.country.fi=Finlande\n# suppress inspection \"UnusedProperty\"\nenum.country.fr=France\n# suppress inspection \"UnusedProperty\"\nenum.country.gf=Guyane Française\n# suppress inspection \"UnusedProperty\"\nenum.country.pf=Polynésie française\n# suppress inspection \"UnusedProperty\"\nenum.country.tf=Terres australes françaises\n# suppress inspection \"UnusedProperty\"\nenum.country.ga=Gabon\n# suppress inspection \"UnusedProperty\"\nenum.country.gm=Gambie\n# suppress inspection \"UnusedProperty\"\nenum.country.ge=Géorgie\n# suppress inspection \"UnusedProperty\"\nenum.country.de=Allemagne\n# suppress inspection \"UnusedProperty\"\nenum.country.gh=Gnana\n# suppress inspection \"UnusedProperty\"\nenum.country.gi=Gibraltar\n# suppress inspection \"UnusedProperty\"\nenum.country.gr=Grèce\n# suppress inspection \"UnusedProperty\"\nenum.country.gl=Groenland\n# suppress inspection \"UnusedProperty\"\nenum.country.gd=Grenade\n# suppress inspection \"UnusedProperty\"\nenum.country.gp=Guadeloupe\n# suppress inspection \"UnusedProperty\"\nenum.country.gu=Guam\n# suppress inspection \"UnusedProperty\"\nenum.country.gt=Guatemala\n# suppress inspection \"UnusedProperty\"\nenum.country.gg=Guernesey\n# suppress inspection \"UnusedProperty\"\nenum.country.gn=Guinée\n# suppress inspection \"UnusedProperty\"\nenum.country.gw=Guinée-Bissau\n# suppress inspection \"UnusedProperty\"\nenum.country.gy=Guyane\n# suppress inspection \"UnusedProperty\"\nenum.country.ht=Haïti\n# suppress inspection \"UnusedProperty\"\nenum.country.hm=Île Heard et îles McDonald\n# suppress inspection \"UnusedProperty\"\nenum.country.va=Saint-Siège (État de la Cité du Vatican)\n# suppress inspection \"UnusedProperty\"\nenum.country.hn=Honduras\n# suppress inspection \"UnusedProperty\"\nenum.country.hk=Hong Kong\n# suppress inspection \"UnusedProperty\"\nenum.country.hu=Hongrie\n# suppress inspection \"UnusedProperty\"\nenum.country.is=Islande\n# suppress inspection \"UnusedProperty\"\nenum.country.in=Inde\n# suppress inspection \"UnusedProperty\"\nenum.country.id=Indonésie\n# suppress inspection \"UnusedProperty\"\nenum.country.ir=Iran\n# suppress inspection \"UnusedProperty\"\nenum.country.iq=Irak\n# suppress inspection \"UnusedProperty\"\nenum.country.ie=Irlande\n# suppress inspection \"UnusedProperty\"\nenum.country.im=île de Man\n# suppress inspection \"UnusedProperty\"\nenum.country.il=Israël\n# suppress inspection \"UnusedProperty\"\nenum.country.it=Italie\n# suppress inspection \"UnusedProperty\"\nenum.country.jm=Jamaïque\n# suppress inspection \"UnusedProperty\"\nenum.country.jp=Japon\n# suppress inspection \"UnusedProperty\"\nenum.country.je=Jersey\n# suppress inspection \"UnusedProperty\"\nenum.country.jo=Jordanie\n# suppress inspection \"UnusedProperty\"\nenum.country.kz=Kazakhstan\n# suppress inspection \"UnusedProperty\"\nenum.country.ke=Kenya\n# suppress inspection \"UnusedProperty\"\nenum.country.ki=Kiribati\n# suppress inspection \"UnusedProperty\"\nenum.country.kp=Corée du nord\n# suppress inspection \"UnusedProperty\"\nenum.country.kr=Corée du sud\n# suppress inspection \"UnusedProperty\"\nenum.country.kw=Koweit\n# suppress inspection \"UnusedProperty\"\nenum.country.kg=Kirghizistan\n# suppress inspection \"UnusedProperty\"\nenum.country.la=République démocratique populaire de Lao\n# suppress inspection \"UnusedProperty\"\nenum.country.lv=Lettonie\n# suppress inspection \"UnusedProperty\"\nenum.country.lb=Liban\n# suppress inspection \"UnusedProperty\"\nenum.country.ls=Lesotho\n# suppress inspection \"UnusedProperty\"\nenum.country.lr=Libéria\n# suppress inspection \"UnusedProperty\"\nenum.country.ly=Libye\n# suppress inspection \"UnusedProperty\"\nenum.country.li=Liechtenstein\n# suppress inspection \"UnusedProperty\"\nenum.country.lt=Lituanie\n# suppress inspection \"UnusedProperty\"\nenum.country.lu=Luxembourg\n# suppress inspection \"UnusedProperty\"\nenum.country.mo=Macao\n# suppress inspection \"UnusedProperty\"\nenum.country.mk=Macédoine\n# suppress inspection \"UnusedProperty\"\nenum.country.mg=Madagascar\n# suppress inspection \"UnusedProperty\"\nenum.country.mw=Malawi\n# suppress inspection \"UnusedProperty\"\nenum.country.my=Malaisie\n# suppress inspection \"UnusedProperty\"\nenum.country.mv=Maldives\n# suppress inspection \"UnusedProperty\"\nenum.country.ml=Mali\n# suppress inspection \"UnusedProperty\"\nenum.country.mt=Malte\n# suppress inspection \"UnusedProperty\"\nenum.country.mh=Iles Marshall\n# suppress inspection \"UnusedProperty\"\nenum.country.mq=Martinique\n# suppress inspection \"UnusedProperty\"\nenum.country.mr=Mauritanie\n# suppress inspection \"UnusedProperty\"\nenum.country.mu=Maurice\n# suppress inspection \"UnusedProperty\"\nenum.country.yt=Mayotte\n# suppress inspection \"UnusedProperty\"\nenum.country.mx=Méxique\n# suppress inspection \"UnusedProperty\"\nenum.country.fm=Micronésie\n# suppress inspection \"UnusedProperty\"\nenum.country.md=Moldavie\n# suppress inspection \"UnusedProperty\"\nenum.country.mc=Monaco\n# suppress inspection \"UnusedProperty\"\nenum.country.mn=Mongolie\n# suppress inspection \"UnusedProperty\"\nenum.country.me=Monténégro\n# suppress inspection \"UnusedProperty\"\nenum.country.ms=Montserrat\n# suppress inspection \"UnusedProperty\"\nenum.country.ma=Maroc\n# suppress inspection \"UnusedProperty\"\nenum.country.mz=Mozambique\n# suppress inspection \"UnusedProperty\"\nenum.country.mm=Birmanie\n# suppress inspection \"UnusedProperty\"\nenum.country.na=Namibie\n# suppress inspection \"UnusedProperty\"\nenum.country.nr=Nauru\n# suppress inspection \"UnusedProperty\"\nenum.country.np=Népal\n# suppress inspection \"UnusedProperty\"\nenum.country.nl=Pays bas\n# suppress inspection \"UnusedProperty\"\nenum.country.an=Antilles néerlandaises\n# suppress inspection \"UnusedProperty\"\nenum.country.nc=Nouvelle-Calédonie\n# suppress inspection \"UnusedProperty\"\nenum.country.nz=Nouvelle-Zélande\n# suppress inspection \"UnusedProperty\"\nenum.country.ni=Nicaragua\n# suppress inspection \"UnusedProperty\"\nenum.country.ne=Niger\n# suppress inspection \"UnusedProperty\"\nenum.country.ng=Nigeria\n# suppress inspection \"UnusedProperty\"\nenum.country.nu=Niué\n# suppress inspection \"UnusedProperty\"\nenum.country.nf=l'ile de Norfolk\n# suppress inspection \"UnusedProperty\"\nenum.country.mp=Îles Mariannes du Nord\n# suppress inspection \"UnusedProperty\"\nenum.country.no=Norvège\n# suppress inspection \"UnusedProperty\"\nenum.country.om=Oman\n# suppress inspection \"UnusedProperty\"\nenum.country.pk=Pakistan\n# suppress inspection \"UnusedProperty\"\nenum.country.pw=Palaos\n# suppress inspection \"UnusedProperty\"\nenum.country.ps=Palestine\n# suppress inspection \"UnusedProperty\"\nenum.country.pa=Panama\n# suppress inspection \"UnusedProperty\"\nenum.country.pg=Papouasie Nouvelle Guinée\n# suppress inspection \"UnusedProperty\"\nenum.country.py=Paraguay\n# suppress inspection \"UnusedProperty\"\nenum.country.pe=Pérou\n# suppress inspection \"UnusedProperty\"\nenum.country.ph=Philippines\n# suppress inspection \"UnusedProperty\"\nenum.country.pn=Pitcairn\n# suppress inspection \"UnusedProperty\"\nenum.country.pl=Pologne\n# suppress inspection \"UnusedProperty\"\nenum.country.pt=Portugal\n# suppress inspection \"UnusedProperty\"\nenum.country.pr=Porto Rico\n# suppress inspection \"UnusedProperty\"\nenum.country.qa=Qatar\n# suppress inspection \"UnusedProperty\"\nenum.country.re=Réunion\n# suppress inspection \"UnusedProperty\"\nenum.country.ro=Roumanie\n# suppress inspection \"UnusedProperty\"\nenum.country.ru=Russie\n# suppress inspection \"UnusedProperty\"\nenum.country.rw=Rwanda\n# suppress inspection \"UnusedProperty\"\nenum.country.sh=Sainte-Hélène, Ascension et Tristan da Cunha\n# suppress inspection \"UnusedProperty\"\nenum.country.kn=Saint-Christophe-et-Niévès\n# suppress inspection \"UnusedProperty\"\nenum.country.lc=Sainte-Lucie\n# suppress inspection \"UnusedProperty\"\nenum.country.pm=Saint Pierre and Miquelon\n# suppress inspection \"UnusedProperty\"\nenum.country.vc=Saint-Vincent-et-les-Grenadines\n# suppress inspection \"UnusedProperty\"\nenum.country.ws=Samoa\n# suppress inspection \"UnusedProperty\"\nenum.country.sm=Saint-Marin\n# suppress inspection \"UnusedProperty\"\nenum.country.st=Sao Tomé et Principe\n# suppress inspection \"UnusedProperty\"\nenum.country.sa=Arabie Saoudite\n# suppress inspection \"UnusedProperty\"\nenum.country.sn=Sénégal\n# suppress inspection \"UnusedProperty\"\nenum.country.rs=Serbie\n# suppress inspection \"UnusedProperty\"\nenum.country.sc=Seychelles\n# suppress inspection \"UnusedProperty\"\nenum.country.sl=Sierra Leone\n# suppress inspection \"UnusedProperty\"\nenum.country.sg=Singapour\n# suppress inspection \"UnusedProperty\"\nenum.country.sk=Slovaquie\n# suppress inspection \"UnusedProperty\"\nenum.country.si=Slovénie\n# suppress inspection \"UnusedProperty\"\nenum.country.sb=îles Salomon\n# suppress inspection \"UnusedProperty\"\nenum.country.so=Somalie\n# suppress inspection \"UnusedProperty\"\nenum.country.za=Afrique du Sud\n# suppress inspection \"UnusedProperty\"\nenum.country.gs=Géorgie du Sud et îles Sandwich du Sud\n# suppress inspection \"UnusedProperty\"\nenum.country.ss=Soudan du Sud\n# suppress inspection \"UnusedProperty\"\nenum.country.es=Espagne\n# suppress inspection \"UnusedProperty\"\nenum.country.lk=Sri Lanka\n# suppress inspection \"UnusedProperty\"\nenum.country.sd=Soudan\n# suppress inspection \"UnusedProperty\"\nenum.country.sr=Suriname\n# suppress inspection \"UnusedProperty\"\nenum.country.sj=Svalbard et Jan Mayen\n# suppress inspection \"UnusedProperty\"\nenum.country.sz=Swaziland\n# suppress inspection \"UnusedProperty\"\nenum.country.se=Suède\n# suppress inspection \"UnusedProperty\"\nenum.country.ch=Suisse\n# suppress inspection \"UnusedProperty\"\nenum.country.sy=Syrie\n# suppress inspection \"UnusedProperty\"\nenum.country.tw=Taïwan\n# suppress inspection \"UnusedProperty\"\nenum.country.tj=Tadjikistan\n# suppress inspection \"UnusedProperty\"\nenum.country.tz=Tanzanie\n# suppress inspection \"UnusedProperty\"\nenum.country.th=Thaïlande\n# suppress inspection \"UnusedProperty\"\nenum.country.tl=Timor oriental\n# suppress inspection \"UnusedProperty\"\nenum.country.tg=Togo\n# suppress inspection \"UnusedProperty\"\nenum.country.tk=Tokélaou\n# suppress inspection \"UnusedProperty\"\nenum.country.to=Tonga\n# suppress inspection \"UnusedProperty\"\nenum.country.tt=Trinité et Tobago\n# suppress inspection \"UnusedProperty\"\nenum.country.tn=Tunisie\n# suppress inspection \"UnusedProperty\"\nenum.country.tr=Turquie\n# suppress inspection \"UnusedProperty\"\nenum.country.tm=Turkménistan\n# suppress inspection \"UnusedProperty\"\nenum.country.tc=îles Turques et Caïques\n# suppress inspection \"UnusedProperty\"\nenum.country.tv=Tuvalu\n# suppress inspection \"UnusedProperty\"\nenum.country.ug=Uganda\n# suppress inspection \"UnusedProperty\"\nenum.country.ua=Ukraine\n# suppress inspection \"UnusedProperty\"\nenum.country.ae=Emirats Arabes Unis\n# suppress inspection \"UnusedProperty\"\nenum.country.gb=Royaume-Uni\n# suppress inspection \"UnusedProperty\"\nenum.country.us=États-Unis\n# suppress inspection \"UnusedProperty\"\nenum.country.um=Îles mineures éloignées des États-Unis\n# suppress inspection \"UnusedProperty\"\nenum.country.uy=Uruguay\n# suppress inspection \"UnusedProperty\"\nenum.country.uz=Ouzbékistan\n# suppress inspection \"UnusedProperty\"\nenum.country.vu=Vanuatu\n# suppress inspection \"UnusedProperty\"\nenum.country.ve=Venezuela\n# suppress inspection \"UnusedProperty\"\nenum.country.vn=Viêt Nam\n# suppress inspection \"UnusedProperty\"\nenum.country.vg=Îles Vierges britanniques\n# suppress inspection \"UnusedProperty\"\nenum.country.vi=Îles Vierges, États-Unis\n# suppress inspection \"UnusedProperty\"\nenum.country.wf=Wallis et Futuna\n# suppress inspection \"UnusedProperty\"\nenum.country.eh=Sahara occidental\n# suppress inspection \"UnusedProperty\"\nenum.country.ye=Yémen\n# suppress inspection \"UnusedProperty\"\nenum.country.zm=Zambie\n# suppress inspection \"UnusedProperty\"\nenum.country.zw=Zimbabwe\n# suppress inspection \"UnusedProperty\"\nenum.country.tor=Tor\n# suppress inspection \"UnusedProperty\"\nenum.country.i2p=I2P\n# suppress inspection \"UnusedProperty\"\nenum.country.lan=LAN\n"
  },
  {
    "path": "common/src/main/resources/i18n/messages_ru.properties",
    "content": "#\n# Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n\n# Common\n\nok=ОК\ncancel=Отмена\nclose=Закрыть\nsend=Отправить\ncreate=Создать\nremove=Удалить\ndownload=Скачать\nadd=Добавить\nopen=Открыть\ncopy-link=Копировать адрес ссылки\ncopy=Копировать\nsave-as=Сохранить как...\npaste-id=Вставить свой ID\nundo=Отменить\nredo=Повторить\ncut=Вырезать\npaste=Вставить\ndelete=Удалить\nselect-all=Выделить все\ndeselect-all=Снять выделение\nview-fullscreen=Полноэкранный режим\ncopy-image=Копировать изображение\nsave-image-as=Сохранить изображение как...\nenabled=Включено\nno-results=Результатов не найдено\nskip=Пропустить\nname=Имя\nhelp=Справка\nsettings=Настройки\nexit=Выход\nprofile=Профиль\nsubscribed=Подписаны\nown=Свои\ndescription=Описание\nsubject=Тема\nhash=Хеш\nsize=Размер\ntrust=Доверие\nunknown-lc=неизвестно\nlogo=Логотип\nlatest=Последнее\nupdate=Обновить\nedit=Редактировать\nbody=Основной текст (необязательно)\ntext=Текст\nimage=Изображение\nlink=Ссылка\nmark-read-unread=Отметить как прочитанное/непрочитанное\nthumbnail=Миниатюра\nposts-at-remote-nodes=Сообщения на удалённом узле\nlast-activity=Последняя активность\nstate=Состояние\nip=IP\nport=Порт\n\n# File Requesters\n\nfile-requester.profiles=Файлы профилей\nfile-requester.xml=XML файлы\nfile-requester.png=PNG файлы\nfile-requester.sounds=Звуковые файлы\nfile-requester.select-sound-title=Выберите звуковой файл\nfile-requester.images=Файлы изображений\nfile-requester.save-image-title=Выберите место для сохранения изображения\nfile-requester.error=Ошибка с файлом {0}: {1}\nfile-requester.add-files=Выберите файл(ы) для добавления\n\n# Main\n\n## Menu\n\nmain.menu.add-peer=Добавить участника...\nmain.menu.broadcast=Трансляция...\nmain.menu.shares=Настроить общие ресурсы\nmain.menu.statistics=Показать статистику\n\nmain.menu.tools=Инструменты\nmain.menu.tools.import-from-rs=Импорт друзей из Retroshare...\nmain.menu.tools.export=Экспорт...\n\nmain.menu.help.about=О Xeres\nmain.menu.help.documentation=Документация\nmain.menu.help.report-bug=Сообщить об ошибке ↗\nmain.menu.help.check-for-updates=Проверить обновления... ↗\n\nmain.friends-import-successful=Успешно импортировано {0} местоположений.\nmain.friends-import-errors=Импортировано {0} местоположений, но {1} с ошибками.\n\nmain.systray.peers=Подключено участников: {0,number,integer}\n\n## Splash\n\nsplash.status.database=Загрузка базы данных\nsplash.status.network=Запуск сети\n\n## Content\n\nmain.home=Главная\nmain.contacts=Контакты\nmain.chats=Чаты\nmain.forums=Форумы\nmain.files=Файлы\nmain.boards=Доски\nmain.channels=Каналы\n\nmain.home.slogan=Где Дружба Встречает Свободу\nmain.home.share-id=Это ваш Xeres ID. Поделитесь им с другими людьми.\nmain.home.received-id=Вы получили ID от участника?\nmain.home.add-peer=Добавить участника\nmain.home.add-peer.tip=Добавить друга, вставив его ID\nmain.home.need-help=Нужна помощь?\nmain.home.online-help=Онлайн справка ↗\nmain.home.online-help.tip=Показать онлайн справку (Ctrl+F1)\nmain.home.copy-id.tip=Скопировать Xeres ID в буфер обмена\nmain.home.qrcode.tip=Используйте QR-код для передачи вашего ID. Распечатайте его или сфотографируйте телефоном, затем покажите веб-камере.\n\nmain.select-avatar=Выбрать изображение аватара\nmain.export-profile=Выберите, куда сохранить ваш профиль\nmain.import-friends=Выберите файл друзей Retroshare\n\nmain.scanning=Сканирование {0}...\nmain.hashing=Хеширование {0}\nmain.scanning.tip=Ресурс: {0}, файл: {1}\n\n## Status\n\nmain.status.connections=Подключения:\nmain.status.nat.unknown=Статус еще неизвестен.\nmain.status.nat.firewalled=Клиент недоступен для подключений, инициированных из Интернета.\nmain.status.nat.upnp=UPNP активен и клиент полностью доступен из Интернета.\nmain.status.dht.disabled=DHT отключен.\nmain.status.dht.initializing=DHT в процессе инициализации. Это может занять некоторое время.\nmain.status.dht.running=DHT работает правильно, IP-адрес клиента анонсирован его участникам.\nmain.status.dht.stats=Количество участников: {0,number,integer}\\nПолучено пакетов: {1,number,integer} ({2})\\nОтправлено пакетов: {3,number,integer} ({4})\\nКоличество ключей: {5,number,integer}\\nКоличество элементов: {6,number,integer}\n\nmain.exit.confirm=Вы уверены, что хотите выйти из Xeres?\n\n# Account creation\n\naccount.welcome=Добро пожаловать в Xeres\naccount.welcome.tip=Вам нужно создать профиль и местоположение.\\n\\nПрофиль - это вы сами, вы можете использовать свое имя или псевдоним, а местоположение - это устройство, на котором вы находитесь.\\n\\nУ вас может быть несколько местоположений, например, настольный компьютер и ноутбук, которые используют один и тот же профиль (вас).\\n\\nИспользуйте опцию импорта, чтобы импортировать профиль, который вы уже создали ранее.\\n\\nВсе всегда хранится локально, поэтому не забудьте сделать резервную копию ваших данных.\\n\\nНажмите клавишу F1, чтобы прочитать встроенную документацию, и помните, что если вы наведете указатель мыши на элемент пользовательского интерфейса и задержите его на короткое время, появится описание этого элемента.\naccount.profile.prompt=Имя профиля\naccount.profile.tip=Используйте псевдоним или настоящее имя. У профиля может быть несколько местоположений.\naccount.location=Местоположение\naccount.location.prompt=Имя местоположения\naccount.location.tip=Это ваш экземпляр Xeres на этом устройстве. Используйте псевдоним вашего устройства или его модель.\naccount.options=Опции\naccount.generation.profile-keys=Генерация ключей профиля...\naccount.generation.location-keys-and-certificate=Генерация ключей местоположения и сертификата...\naccount.generation.identity=Генерация идентификатора...\naccount.generation.import=Импорт...\naccount.generation.import.tip=Вы можете импортировать 3 вида профилей:\\n\\nПрофиль, экспортированный из Xeres (xeres_backup.xml).\\n\\nКлючевое хранилище Retroshare (retroshare_secret_keyring.gpg) или профиль, экспортированный из Retroshare (*.asc).\naccount.generation.import.progress=Импорт профиля...\naccount.generation.import.confirm.title=Импортер Retroshare\naccount.generation.import.confirm.prompt=Введите пароль Retroshare\naccount.generation.import.unknown=Неизвестный формат файла\naccount.generation.profile-load=Выберите файл профиля Xeres (xeres_backup.xml), ключевой контейнер Retroshare (retroshare_secret_keyring.gpg) или профиль Retroshare (*.asc)\n\n# Chat\n\n## Common\n\nchat.notification.typing={0} печатает\n\n## Room common\n\nchat.room.id=ID\nchat.room.topic=Тема\nchat.room.security=Безопасность\nchat.room.users=Пользователи\n\nchat.room.info=Тема: {0}\\nПользователи: {1,number,integer}\\nБезопасность: {2}\\nID: {3}\nchat.room.none=[нет]\nchat.room.private=приватная\nchat.room.public=публичная\nchat.room.signed-only=только подписанные ID\nchat.room.anonymous-allowed=анонимные ID разрешены\nchat.room.user-info=Имя: {0}\\nID: {1}\nchat.room.user-menu=Информация\nchat.room.clear-history=Вы действительно хотите очистить историю?\nchat.room.copy-selection=Копировать выделенное\nchat.room.clear-chat-history=Очистить историю чата\n\n## Room create\n\nchat.room.create.window-title=Создать комнату чата\nchat.room.create.name.prompt=Короткое и описательное имя комнаты\nchat.room.create.name.tip=Название комнаты. Используйте правильные заглавные буквы и пробелы.\nchat.room.create.topic.prompt=О чем комната\nchat.room.create.topic.tip=Описание комнаты, о чем она.\nchat.room.create.visibility=Видимость\nchat.room.create.visibility.tip=Публичные комнаты видны участникам.\\nПриватные комнаты нет и работают только по приглашению.\nchat.room.create.security.checkbox=Только подписанные идентификаторы\nchat.room.create.security.tip=Комната, ограниченная для подписанных идентификаторов, более устойчива к спаму, потому что анонимные идентификаторы не могут присоединиться.\nchat.room.create.tooltip=Создать новую комнату чата\n\n## Room invite\n\nchat.room.invite.window-title=Пригласить участника в текущую комнату чата\nchat.room.invite.button=Пригласить\nchat.room.invite.tip=Пригласить участников в текущую комнату чата\nchat.room.invite.request={0} хочет пригласить вас в {1} ({2})\n\nchat.room.join=Присоединиться\nchat.room.leave=Покинуть\n\nchat.room.not-found=Комната не найдена. Вероятно, комната недоступна ни на одном из ваших подключенных друзей.\n\n# Forums\n\ngxs-group.tree.popular=Популярные\ngxs-group.tree.other=Другие\n\ngxs-group.tree.info=Имя: {0}\\nID: {1}\\nУдалённые сообщения: {2}\\nАктивность на сервере: {3}\n\ngxs-group.tree.subscribe=Подписаться\ngxs-group.tree.unsubscribe=Отписаться\n\nforum.new-message.window-title=Новое сообщение\nforum.create.window-title=Создать форум\nforum.create.name.prompt=Короткое и описательное имя форума\nforum.create.name.tip=Название форума. Используйте правильные заглавные буквы и пробелы.\nforum.create.description.prompt=О чем форум\nforum.create.description.tip=Описание форума, о чем он.\n\nforum.editor.name=Форум\nforum.editor.name.prompt=Название форума\nforum.editor.thread.description=Тема ветки\nforum.editor.cancel=Сообщение форума еще не отправлено! Вы действительно хотите отменить это сообщение?\n\nforum.view.create.tip=Создать новый форум\nforum.view.header.author=Автор\nforum.view.header.date=Дата\n\nforum.view.new-message.tip=Создать новое сообщение\n\nforum.view.group.not-found=Форум не найден. Вероятно, он недоступен ни на одном из ваших подключенных друзей.\nforum.view.message.not-found=Сообщение не найдено. Вероятно, сообщение слишком старое или у отправителя слишком низкая репутация.\n\nforum.view.from=От:\nforum.view.subject=Тема:\nforum.view.reply=Ответить\n\nforum.view.history=Этот переключатель позволяет отображать предыдущие версии сообщений.\n\n# Boards\n\nboard.create.window-title=Создать доску\nboard.create.name.prompt=Краткое и понятное название доски\nboard.create.name.tip=Название доски. Используйте заглавные буквы и пробелы по правилам.\nboard.create.description.prompt=Тематика доски\nboard.create.description.tip=Описание доски, её тематика.\nboard.select-logo=Выбрать изображение доски\nboard.select-image=Выбрать изображение для публикации\n\nboard.view.create.tip=Создать новую доску\nboard.view.group.not-found=Доска не найдена. Вероятно, она недоступна ни на одном из ваших подключённых серверов.\n\nboard.new-message.window-title=Новое сообщение на доске\n\nboard.editor.name=Доска\nboard.editor.name.prompt=Название доски\nboard.editor.thread.title=Заголовок\nboard.editor.post.description=Заголовок сообщения\n\nboard.editor.cancel=Сообщение на доске ещё не отправлено! Вы действительно хотите отменить его?\n\nboard.posted-by=Опубликовано\nboard.on=в\n\n# Channels\n\nchannel.view.create.tip=Создать новый канал\nchannel.create.window-title=Создать канал\nchannel.create.name.prompt=Краткое и понятное название канала\nchannel.create.name.tip=Название канала. Используйте заглавные буквы и пробелы по правилам.\nchannel.create.description.prompt=Тематика канала\nchannel.create.description.tip=Описание канала, его тематика.\nchannel.select-image=Выбрать изображение для сообщения в канале\n\nchannel.view.group.not-found=Канал не найден. Вероятно, он недоступен ни на одном из ваших подключённых серверов.\n\nchannel.select-logo=Выбрать изображение канала\n\nchannel.new-message.window-title=Новое сообщение в канале\n\nchannel.editor.name=Канал\nchannel.editor.name.prompt=Название канала\nchannel.editor.thread.title=Заголовок\nchannel.editor.post.description=Заголовок сообщения\n\nchannel.editor.cancel=Сообщение в канале ещё не отправлено! Вы действительно хотите отменить его?\nchannel.clipboard.error=Буфер обмена не содержит ссылок на файлы.\nchannel.files=Файлы\nchannel.post=Опубликовать\nchannel.drag-drop=Добавьте файлы или перетащите их сюда\nchannel.add-files=Добавить файл(ы)\nchannel.paste-links=Вставить ссылку(и)\nchannel.remove-files=Удалить файл(ы)\n\n# Add RSID\n\nrs-id.add.window-title=Добавить участника\nrs-id.add.textarea.prompt=Вставьте ID участника\nrs-id.add.textarea.tip=ID - это строка длиной около сотни символов base64. Она кодирует всю информацию, необходимую для подключения к участнику.\nrs-id.add.details=Детали участника\nrs-id.add.name.tip=Имя участника, убедитесь, что вы знаете, кто это.\nrs-id.add.profile=ID профиля\nrs-id.add.profile.tip=Уникальный ID для проверки правильности профиля вашего участника.\nrs-id.add.fingerprint=Отпечаток\nrs-id.add.fingerprint.tip=Криптографическая контрольная сумма для подтверждения подлинности профиля вашего участника.\nrs-id.add.location=ID местоположения\nrs-id.add.location.tip=Идентификатор местоположения. У профиля может быть несколько местоположений, и у каждого есть уникальный ID.\nrs-id.add.addresses=Адреса\nrs-id.add.addresses.tip=Адреса для подключения. Они все будут пробоваться по очереди, но вы можете предварительно выбрать лучший для более быстрого первоначального подключения.\\nАдреса, оканчивающиеся на .onion, требуют использования прокси Tor.\\nАдреса, оканчивающиеся на .i2p, требуют использования прокси I2P.\nrs-id.add.trust.tip=Уровень доверия к участнику.\\nНеизвестно: нет мнения.\\nНикогда: нет или минимальное, встретились онлайн недавно.\\nЧастичное: более или менее доверенный, знакомый.\\nПолное: очень доверенный, хороший друг.\nrs-id.add.invalid=Неверный ID\nrs-id.add.scan=Сканируйте код QR с помощью камеры.\n\n# Broadcast\n\nbroadcast.window-title=Трансляция\nbroadcast.send.explanation=Отправить сообщение всем текущим подключенным участникам.\nbroadcast.send.warning-header=Предупреждение:\nbroadcast.send.warning=не злоупотребляйте этой функцией. Используйте ее только в чрезвычайных или исключительных ситуациях.\n\n# Messaging\n\nmessaging.prompt=Введите сообщение\nmessaging.file-requester.send-picture=Выберите изображение для отправки в сообщении\nmessaging.file-requester.send-file=Выберите файл для отправки\nmessaging.send-picture=Выберите изображение для отправки в сообщении\nmessaging.send-sticker=Отправить стикер\nmessaging.send.file=Выберите файл для отправки\nmessaging.action.call=Совершить прямой звонок\nmessaging.action.send-inline=Отправить изображение встроенным\nmessaging.action.send-file=Отправить файл\n\nmessaging.warning.title=Предупреждение\nmessaging.warning.description=Пользователь в настоящее время не в сети и не может получать сообщения.\n\nmessaging.tunneling=Попытка установить туннель...\n\nmessaging.closing-tunnel.confirm=Закрытие этого окна завершит удаленный чат и удалит все неотправленные сообщения. Вы уверены?\n\n# Profiles\n\nprofiles.delete=Удалить профиль\n\n# About\n\nabout.window-title=О {0}\nabout.version=Версия:\nabout.title=О программе\nabout.slogan=Друг-к-Другу, децентрализованное и безопасное приложение для общения и обмена\nabout.authors=Авторы\nabout.author-by=от\nabout.all-rights-reserved=Все права защищены\nabout.report-bugs=Сообщить об ошибках или предложить улучшения.\nabout.website=Веб-сайт\nabout.wiki=Вики\nabout.source-code=Исходный код\nabout.thanks=Благодарности\nabout.license=Лицензия\nabout.additional-licenses=Дополнительные лицензии\nabout.release=Релиз\nabout.profiles=Профили:\n\n# QR Code\n\nqr-code.window-title=QR код\nqr-code.print=Печать...\nqr-code.save-as-png=Сохранить как PNG\nqr-code.download-client=Скачать клиент на https://xeres.io\nqr-code.camera.error=Камера не обнаружена\n\n# Camera\n\ncamera.window-title=Сканировать QR код\n\n# Settings\n\n## Main\n\nsettings.general=Общие\nsettings.network=Сеть\nsettings.transfer=Передача\nsettings.notifications=Уведомления\nsettings.sound=Звук\nsettings.remote=Удаленный доступ\nsettings.directory.no-remote=Невозможно выбрать каталог в удаленном режиме\n\n## General\n\nsettings.general.theme=Тема\nsettings.general.system=Системная\nsettings.general.startup=Запускать при загрузке системы\nsettings.general.startup.tip=Запускать автоматически при запуске системы, свернутым в трей.\nsettings.general.startup.not-available=Недоступно. Либо ОС не поддерживается, либо вы работаете в портативном режиме.\nsettings.general.update-check=Автоматически проверять обновления\nsettings.general.update-check.tip=Автоматически проверяет GitHub раз в день на наличие нового релиза.\n\n## Network\n\nsettings.network.hidden-services=Скрытые сервисы\nsettings.network.tor-proxy=Socks прокси Tor\nsettings.network.tor-proxy.prompt=Сервер Tor\nsettings.network.tor-proxy.tip=IP-адрес или имя хоста Tor SOCKS v5, обычно 127.0.0.1, если работает на том же хосте.\nsettings.network.tor-port.tip=Порт Tor SOCKS v5, обычно 9050.\nsettings.network.i2p-proxy=Socks прокси I2P\nsettings.network.i2p-proxy.prompt=Сервер I2P\nsettings.network.i2p-proxy.tip=IP-адрес или имя хоста I2P SOCKS v5, обычно 127.0.0.1, если работает на том же хосте.\nsettings.network.i2p-port.tip=Порт I2P SOCKS v5, обычно 4447.\nsettings.network.use-upnp=Использовать UPNP\nsettings.network.use-upnp.tip=UPNP (Universal Plug and Play) позволяет автоматически настроить правильные входящие порты в вашем маршрутизаторе. Это улучшает надежность подключения от ваших участников.\nsettings.network.external-ip-and-port=Внешний IP и порт\nsettings.network.external-ip-and-port.tip=Внешний IP-адрес и порт вашего местоположения. Так ваше подключение выглядит в Интернете.\nsettings.network.use-broadcast-discovery=Включить обнаружение по широковещательной рассылке\nsettings.network.use-broadcast-discovery.tip=Обнаружение по широковещательной рассылке позволяет сообщать ваш IP и порт другим местоположениям в вашей локальной сети. Это улучшает надежность подключения от возможных участников в вашей локальной сети.\nsettings.network.internal-ip-and-port=Внутренний IP и порт\nsettings.network.internal-ip-and-port.tip=Внутренний IP-адрес и порт вашего местоположения. Так ваше подключение выглядит в вашей локальной сети (LAN).\nsettings.network.use-dht=Включить DHT\nsettings.network.use-dht.tip=DHT (Distributed Hash Table) позволяет участникам находить IP-адреса друг друга, когда они меняются. Это улучшает связность при роуминге.\n\n## Remote\n\nsettings.remote.title=Удаленный доступ\nsettings.remote.username=Имя пользователя\nsettings.remote.password=Пароль\nsettings.remote.note=Установка пустого пароля отключает аутентификацию.\nsettings.remote.enabled.tip=Включить удаленный доступ. Этот экземпляр затем может быть доступен либо из другого экземпляра Xeres, либо из клиента Android.\nsettings.remote.upnp-set=Установить с UPNP\nsettings.remote.upnp-set.tip=Установить удаленный порт с UPNP, сделав его доступным из WAN.\nsettings.remote.restart=Вам необходимо перезапустить Xeres, чтобы изменения удаленного доступа вступили в силу. Выйти сейчас?\nsettings.remote.view-api=Просматривать API\n\n## Transfer\n\nsettings.transfer.select-incoming=Выбрать папку загрузок\nsettings.transfer.incoming=Папка загрузок\n\n## Notifications\n\nsettings.notifications.show-connections=Показывать подключения\nsettings.notifications.show-connections.tip=Показывает, когда устанавливается соединение с другом.\nsettings.notifications.show-broadcasts=Показывать трансляции\nsettings.notifications.show-broadcasts.tip=Показывает трансляции сообщений, отправленные друзьями.\nsettings.notifications.show-discovery=Показывать обнаружение\nsettings.notifications.show-discovery.tip=Показывает, когда в локальной сети появляется клиент с включенным обнаружением по широковещательной рассылке.\n\n## Sound\n\nsettings.sound.message=Получено сообщение\nsettings.sound.message.tip=Воспроизводит звук, когда получено личное сообщение и окно неактивно.\nsettings.sound.highlight=Упоминание\nsettings.sound.highlight.tip=Воспроизводит звук, когда кто-то обращается к вам в комнате чата.\nsettings.sound.friend=Друг подключился\nsettings.sound.friend.tip=Воспроизводит звук, когда друг подключается к вам.\nsettings.sound.download=Загрузка завершена\nsettings.sound.download.tip=Воспроизводит звук, когда загрузка завершена.\nsettings.sound.ringing=Запрос\nsettings.sound.ringing.tip=Воспроизводит звук при приеме или совершении звонка.\n\n# Share\n\nshare.window-title=Общие ресурсы\nshare.select-directory=Выберите каталог для общего доступа\nshare.remove=Удалить общий ресурс\nshare.error.empty-name=Имя общего ресурса не может быть пустым. Установите уникальное имя.\nshare.error.empty-path=Путь общего ресурса не может быть пустым. Установите путь общего ресурса.\nshare.error.not-unique=Имя общего ресурса уже существует. Каждое имя общего ресурса должно быть уникальным.\n\nshare.list.directory=Общий каталог\nshare.list.visible-name=Видимое имя\nshare.list.searchable=Доступно для поиска\nshare.list.browsable=Доступно для просмотра\n\nshare.create=Создать новый общий ресурс\nshare.apply=Применить и закрыть\n\n# Tray\n\ntray.open=Открыть {0}\ntray.peers=Участники\ntray.status=Статус\n\n# EditorView\n\neditor.hyperlink.enter=Введите URL\neditor.action.undo=Отменить (Ctrl+Z)\neditor.action.redo=Повторить (Ctrl+Shift+Z)\neditor.action.bold=Жирный (Ctrl+B)\neditor.action.italic=Курсив (Ctrl+I)\neditor.action.hyperlink=Ссылка (Ctrl+L)\neditor.action.quote=Цитата (Ctrl+Q)\neditor.action.code=Код (Ctrl+K)\neditor.action.unordered-list=Маркированный список (Ctrl+U)\neditor.action.ordered-list=Нумерованный список (Ctrl+Shift+U)\neditor.action.header=Заголовок (Ctrl+1)\neditor.action.preview=Предпросмотр сообщения (F12)\n\n# Search / Download / Uploads\n\nsearch.main.search=Поиск\nsearch.main.downloads=Загрузки\nsearch.main.uploads=Отправки\nsearch.main.trends=Тренды\n\nsearch.input.prompt=Введите условия поиска\nsearch.input.search.tip=Ввод нескольких условий поиска будет искать файлы, содержащие все их. Используйте \\\" вокруг условий поиска для точного совпадения.\nsearch.searching=Поиск...\n\ntrends.none=Пока нет трендов\ntrends.list.terms=Термины\ntrends.list.from=От\ntrends.list.time=Время\n\ndownload-view.list.none=Нет загрузок\ndownload-view.list.state=Состояние\ndownload-view.list.progress=Прогресс\ndownload-view.list.total-size=Общий размер\n\ndownload-view.show-in-folder=Показать в папке\n\ndownload-view.open-error=Не удалось открыть:\ndownload-view.show-error=Не удалось показать в проводнике:\n\ndownload-add.window-title=Добавить загрузку\ndownload-add.bytes={0,number,integer} байт\n\nupload-view.none=Нет отправляемых файлов\n\nfile-result.column.type=Тип\n\n# StatisticsTurtle\n\nstatistics.window-title=Статистика\n\nstatistics.elapsed-time=Прошедшее время (секунды)\n\nstatistics.turtle.data-in=Входящие данные\nstatistics.turtle.data-in.tip=Полученные данные контента (загрузки)\nstatistics.turtle.data-out=Исходящие данные\nstatistics.turtle.data-out.tip=Отправленные данные контента (отправки)\nstatistics.turtle.data-forward=Переданные данные\nstatistics.turtle.data-forward.tip=Данные контента, переданные другим участникам\nstatistics.turtle.tunnel-in=Входящие туннельные запросы\nstatistics.turtle.tunnel-in.tip=Входящие запросы на туннелирование\nstatistics.turtle.tunnel-out=Исходящие туннельные запросы\nstatistics.turtle.tunnel-out.tip=Переданные запросы на туннелирование и собственные запросы\nstatistics.turtle.search-in=Входящие поисковые запросы\nstatistics.turtle.search-in.tip=Входящие поисковые запросы\nstatistics.turtle.search-out=Исходящие поисковые запросы\nstatistics.turtle.search-out.tip=Переданные поисковые запросы и наши собственные запросы\nstatistics.turtle.bandwidth=Пропускная способность\nstatistics.turtle.speed=Скорость (КБ/с)\nstatistics.turtle.tip=На графике показана статистика turtle маршрутизатора. Она состоит из:\\nПоисковые запросы: поиск файлов (название, размер и т.д.).\\nТуннельные запросы: настройка туннелей между удаленными участниками для подготовки передачи файлов.\\nЗапросы данных: данные, которые передаются внутри туннелей.\\nБольшинство запросов и данных, не предназначенных для нашего собственного узла, передаются другим участникам в пределах заданной вероятности, которая варьируется в зависимости от расстояния.\n\nstatistics.rtt.rtt=Время кругового пути\nstatistics.rtt.time=RTT (миллисекунды)\nstatistics.rtt.tip=RTT (Round Trip Time) - это время, необходимое для отправки сообщения получателю и получения ответа обратно отправителю. Это дает представление о сетевой задержке между участниками.\\nОжидайте проблем, если RTT слишком высок (более нескольких секунд).\n\nstatistics.data-counter.title=Использование данных\nstatistics.data-counter.data=Данные (КБ)\nstatistics.data-counter.tip=На этом графике показано количество данных, поступающих от участников и отправляемых им.\nstatistics.data-counter.peers=Участники\n\nstatistics.turtle=Turtle\nstatistics.rtt=RTT\nstatistics.data-usage=Использование данных\n\n# ContactView\n\ncontact-view.profile-delete.confirm=Это удалит и отключит вас от профиля {0}. Вы действительно хотите это сделать?\ncontact-view.avatar-delete.confirm=Вы действительно хотите удалить изображение вашего аватара?\ncontact-view.location.last-connected.now=Сейчас\ncontact-view.location.last-connected.never=Никогда\ncontact-view.information.linked-to-profile=Идентификатор привязан к профилю\ncontact-view.information.profile=Профиль\ncontact-view.information.identity=Идентификатор\ncontact-view.information.type=Тип\ncontact-view.information.created=Создан\ncontact-view.information.updated=Обновлен\ncontact-view.information.created-unknown=неизвестно\ncontact-view.information.key-information-with-length=Версия: {0}\\nАлгоритм: {1}\\nДлина: {2} бит\\nХеш подписи: {3}\ncontact-view.information.key-information=Версия: {0}\\nАлгоритм: {1}\\nХеш подписи: {2}\ncontact-view.open.identity-not-found=Идентификатор не найден\ncontact-view.open.profile-not-found=Профиль не найден\ncontact-view.information.location.id=ID местоположения:\ncontact-view.information.location.version=Версия:\ncontact-view.search.prompt=Поиск людей\ncontact-view.search.show-all=Показать все контакты\ncontact-view.search.no-contacts=Нет контактов\ncontact-view.badge.own=Свой\ncontact-view.badge.own.tip=Это вы сами.\ncontact-view.badge.partial=Частичный\ncontact-view.badge.partial.tip=Частичный контакт еще не подкреплен полным профилем. Необходимо подключиться к нему хотя бы один раз, затем он будет проверен и, в случае успеха, повышен до полного профиля.\ncontact-view.badge.accepted=Принят\ncontact-view.badge.accepted.tip=Этот контакт принят для входящих подключений, и исходящие подключения к нему также attempted.\ncontact-view.badge.not-validated=Еще не проверен\ncontact-view.badge.not-validated.tip=Контакт еще не проверен. Его подпись профиля будет проверена в ближайшее время и, в случае успеха, будет помечен как действительный. Если неудачно, он будет удален (но может быть передан снова, если так, попробуйте сообщить его владельцу о проблеме).\ncontact-view.action.chat=Чат\ncontact-view.action.distant-chat=Удаленный чат\ncontact-view.action.connect=Попытаться подключиться\ncontact-view.information.locations=Местоположения\ncontact-view.column.last-connected=Последнее подключение\ncontact-view.chat.start=Начать прямой чат\ncontact-view.distant-chat.start=Начать удаленный чат\n\n# ImageSelectorView\n\nimage-selector-view.change-image=Изменить изображение...\nimage-selector-view.change-image-short=Изменить\nimage-selector-view.add-image=Добавить изображение...\n\n# VoIP\n\nvoip.window-title=Звонок\nvoip.action.message=Сообщение\nvoip.action.message.tip=Отправить прямое сообщение чата пользователю\nvoip.action.recall=Позвонить снова\nvoip.action.recall.tip=Перезвонить пользователю\nvoip.action.close.tip=Закрыть окно\nvoip.action.answer=Ответить\nvoip.action.reject=Отклонить\nvoip.action.hangup=Завершить звонок\nvoip.action.window-quit=Вы уверены, что хотите прервать звонок?\nvoip.status.incoming=Входящий звонок...\nvoip.status.calling=Звонок...\nvoip.status.ongoing=В разговоре\nvoip.status.ended=Звонок завершен\n\n# Update\n\nupdate.latest-already=У вас уже установлена последняя версия.\nupdate.new-version=Доступна новая версия ({0}). Скачать, проверить и установить?\nupdate.new-version-auto=Доступна новая версия ({0}).\nupdate.download-failure=Не удалось скачать URL и/или URL подписи\nupdate.download-file=Загрузка файла...\nupdate.download.title=Обновление Xeres\nupdate.download.verifying=Проверка файла...\nupdate.download.install=Установить\nupdate.download.install-ready=Готово к установке!\nupdate.download-verification-failed=Проверка не удалась!\n\n# Stickers\n\nstickers.instructions=Добавьте свои стикеры в {0}\\n\\nОдна папка на коллекцию стикеров, каждая содержит PNG или JPEG.\n\n# ChatCommands\n\nchat-command.code=Отправить текст как блок кода\nchat-command.coin=Подбросить монетку\nchat-command.me=Отправить сообщение-действие от третьего лица\nchat-command.pre=Отправить текст как предформатированный\nchat-command.quote=Отправить текст как цитату\nchat-command.random=Отправить случайное число от 1 до 10\nchat-command-send=Отправить {0}\n\n# Misc\n\nuri.malicious-link=Предупреждение! Это вредоносная ссылка, нажатие приведет вас к: {0}\nuri.unsafe-link=Внимание! Эта ссылка может быть небезопасной. Переход по ней приведёт вас на: {0}\nuri.malicious-link.confirm=Предупреждение! Это вредоносная ссылка, она приведет вас к {0}. Вы действительно хотите перейти?\nuri.unsafe-link.confirm=Внимание! Эта ссылка может быть небезопасной. Она ведёт на {0}. Вы знаете, что это такое, и доверяете этому?\n\ncontent-image.exit=Нажмите ESC или щелкните для выхода\n\nwebsocket.disconnected=Соединение WebSocket разорвано. Чат недоступен. Переподключиться?\n\n# TrustConverter\n\ntrust-converter.nobody=Никто\ntrust-converter.everybody=Все\ntrust-converter.marginal=Частичные доверенные лица\ntrust-converter.full=Полные доверенные лица\ntrust-converter.ultimate=Только я\n\n# Byte units\n\nbyte-unit.invalid=недействительно\nbyte-unit.bytes=байт\nbyte-unit.kb=КБ\nbyte-unit.mb=МБ\nbyte-unit.gb=ГБ\nbyte-unit.tb=ТБ\nbyte-unit.pb=ПБ\nbyte-unit.eb=ЭБ\n\n# Help\n\nhelp.back=Назад по истории\nhelp.forward=Вперед по истории\nhelp.home=На главную\n\n# Enums (beware of the key naming which must be the same as the class!)\n\n## Trust\n\n# suppress inspection \"UnusedProperty\"\nenum.trust.unknown=Неизвестно\n# suppress inspection \"UnusedProperty\"\nenum.trust.never=Никогда\n# suppress inspection \"UnusedProperty\"\nenum.trust.marginal=Частичное\n# suppress inspection \"UnusedProperty\"\nenum.trust.full=Полное\n# suppress inspection \"UnusedProperty\"\nenum.trust.ultimate=Абсолютное\n\n## Availability\n\n# suppress inspection \"UnusedProperty\"\nenum.availability.available=Доступен\n# suppress inspection \"UnusedProperty\"\nenum.availability.busy=Занят\n# suppress inspection \"UnusedProperty\"\nenum.availability.away=Отошел\n# suppress inspection \"UnusedProperty\"\nenum.availability.offline=Не в сети\n\n## RoomType\n\n# suppress inspection \"UnusedProperty\"\nenum.room-type.private=Приватная\n# suppress inspection \"UnusedProperty\"\nenum.room-type.public=Публичная\n\n## FileType\n\n# suppress inspection \"UnusedProperty\"\nenum.file-type.any=Любой\n# suppress inspection \"UnusedProperty\"\nenum.file-type.audio=Аудио\n# suppress inspection \"UnusedProperty\"\nenum.file-type.archive=Архив\n# suppress inspection \"UnusedProperty\"\nenum.file-type.document=Документ\n# suppress inspection \"UnusedProperty\"\nenum.file-type.picture=Изображение\n# suppress inspection \"UnusedProperty\"\nenum.file-type.program=Программа\n# suppress inspection \"UnusedProperty\"\nenum.file-type.video=Видео\n# suppress inspection \"UnusedProperty\"\nenum.file-type.subtitles=Субтитры\n# suppress inspection \"UnusedProperty\"\nenum.file-type.collection=Коллекция\n# suppress inspection \"UnusedProperty\"\nenum.file-type.directory=Каталог\n\n## FileProgressDisplay State\n\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.searching=Поиск\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.transferring=Передача\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.removing=Удаление\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.done=Готово\n\n## FileAttachment State\n\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.hashing=Хеширование\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.done=Готово\n\n## Country\n\n# suppress inspection \"UnusedProperty\"\nenum.country.af=Афганистан\n# suppress inspection \"UnusedProperty\"\nenum.country.al=Албания\n# suppress inspection \"UnusedProperty\"\nenum.country.dz=Алжир\n# suppress inspection \"UnusedProperty\"\nenum.country.as=Американское Самоа\n# suppress inspection \"UnusedProperty\"\nenum.country.ad=Андорра\n# suppress inspection \"UnusedProperty\"\nenum.country.ao=Ангола\n# suppress inspection \"UnusedProperty\"\nenum.country.ai=Ангилья\n# suppress inspection \"UnusedProperty\"\nenum.country.aq=Антарктида\n# suppress inspection \"UnusedProperty\"\nenum.country.ag=Антигуа и Барбуда\n# suppress inspection \"UnusedProperty\"\nenum.country.ar=Аргентина\n# suppress inspection \"UnusedProperty\"\nenum.country.am=Армения\n# suppress inspection \"UnusedProperty\"\nenum.country.aw=Аруба\n# suppress inspection \"UnusedProperty\"\nenum.country.au=Австралия\n# suppress inspection \"UnusedProperty\"\nenum.country.at=Австрия\n# suppress inspection \"UnusedProperty\"\nenum.country.az=Азербайджан\n# suppress inspection \"UnusedProperty\"\nenum.country.bs=Багамы\n# suppress inspection \"UnusedProperty\"\nenum.country.bh=Бахрейн\n# suppress inspection \"UnusedProperty\"\nenum.country.bd=Бангладеш\n# suppress inspection \"UnusedProperty\"\nenum.country.bb=Барбадос\n# suppress inspection \"UnusedProperty\"\nenum.country.by=Беларусь\n# suppress inspection \"UnusedProperty\"\nenum.country.be=Бельгия\n# suppress inspection \"UnusedProperty\"\nenum.country.bz=Белиз\n# suppress inspection \"UnusedProperty\"\nenum.country.bj=Бенин\n# suppress inspection \"UnusedProperty\"\nenum.country.bm=Бермуды\n# suppress inspection \"UnusedProperty\"\nenum.country.bt=Бутан\n# suppress inspection \"UnusedProperty\"\nenum.country.bo=Боливия\n# suppress inspection \"UnusedProperty\"\nenum.country.ba=Босния и Герцеговина\n# suppress inspection \"UnusedProperty\"\nenum.country.bw=Ботсвана\n# suppress inspection \"UnusedProperty\"\nenum.country.bv=Остров Буве\n# suppress inspection \"UnusedProperty\"\nenum.country.br=Бразилия\n# suppress inspection \"UnusedProperty\"\nenum.country.io=Британская территория в Индийском океане\n# suppress inspection \"UnusedProperty\"\nenum.country.bn=Бруней\n# suppress inspection \"UnusedProperty\"\nenum.country.bg=Болгария\n# suppress inspection \"UnusedProperty\"\nenum.country.bf=Буркина-Фасо\n# suppress inspection \"UnusedProperty\"\nenum.country.bi=Бурунди\n# suppress inspection \"UnusedProperty\"\nenum.country.kh=Камбоджа\n# suppress inspection \"UnusedProperty\"\nenum.country.cm=Камерун\n# suppress inspection \"UnusedProperty\"\nenum.country.ca=Канада\n# suppress inspection \"UnusedProperty\"\nenum.country.cv=Кабо-Верде\n# suppress inspection \"UnusedProperty\"\nenum.country.ky=Каймановы острова\n# suppress inspection \"UnusedProperty\"\nenum.country.cf=Центральноафриканская Республика\n# suppress inspection \"UnusedProperty\"\nenum.country.td=Чад\n# suppress inspection \"UnusedProperty\"\nenum.country.cl=Чили\n# suppress inspection \"UnusedProperty\"\nenum.country.cn=Китай\n# suppress inspection \"UnusedProperty\"\nenum.country.cx=Остров Рождества\n# suppress inspection \"UnusedProperty\"\nenum.country.cc=Кокосовые (Килинг) острова\n# suppress inspection \"UnusedProperty\"\nenum.country.co=Колумбия\n# suppress inspection \"UnusedProperty\"\nenum.country.km=Коморы\n# suppress inspection \"UnusedProperty\"\nenum.country.cg=Конго\n# suppress inspection \"UnusedProperty\"\nenum.country.cd=Демократическая Республика Конго\n# suppress inspection \"UnusedProperty\"\nenum.country.ck=Острова Кука\n# suppress inspection \"UnusedProperty\"\nenum.country.cr=Коста-Рика\n# suppress inspection \"UnusedProperty\"\nenum.country.ci=Кот-д'Ивуар\n# suppress inspection \"UnusedProperty\"\nenum.country.hr=Хорватия\n# suppress inspection \"UnusedProperty\"\nenum.country.cu=Куба\n# suppress inspection \"UnusedProperty\"\nenum.country.cy=Кипр\n# suppress inspection \"UnusedProperty\"\nenum.country.cz=Чехия\n# suppress inspection \"UnusedProperty\"\nenum.country.dk=Дания\n# suppress inspection \"UnusedProperty\"\nenum.country.dj=Джибути\n# suppress inspection \"UnusedProperty\"\nenum.country.dm=Доминика\n# suppress inspection \"UnusedProperty\"\nenum.country.do=Доминиканская Республика\n# suppress inspection \"UnusedProperty\"\nenum.country.ec=Эквадор\n# suppress inspection \"UnusedProperty\"\nenum.country.eg=Египет\n# suppress inspection \"UnusedProperty\"\nenum.country.sv=Сальвадор\n# suppress inspection \"UnusedProperty\"\nenum.country.gq=Экваториальная Гвинея\n# suppress inspection \"UnusedProperty\"\nenum.country.er=Эритрея\n# suppress inspection \"UnusedProperty\"\nenum.country.ee=Эстония\n# suppress inspection \"UnusedProperty\"\nenum.country.et=Эфиопия\n# suppress inspection \"UnusedProperty\"\nenum.country.fk=Фолклендские острова (Мальвинские)\n# suppress inspection \"UnusedProperty\"\nenum.country.fo=Фарерские острова\n# suppress inspection \"UnusedProperty\"\nenum.country.fj=Фиджи\n# suppress inspection \"UnusedProperty\"\nenum.country.fi=Финляндия\n# suppress inspection \"UnusedProperty\"\nenum.country.fr=Франция\n# suppress inspection \"UnusedProperty\"\nenum.country.gf=Французская Гвиана\n# suppress inspection \"UnusedProperty\"\nenum.country.pf=Французская Полинезия\n# suppress inspection \"UnusedProperty\"\nenum.country.tf=Французские Южные территории\n# suppress inspection \"UnusedProperty\"\nenum.country.ga=Габон\n# suppress inspection \"UnusedProperty\"\nenum.country.gm=Гамбия\n# suppress inspection \"UnusedProperty\"\nenum.country.ge=Грузия\n# suppress inspection \"UnusedProperty\"\nenum.country.de=Германия\n# suppress inspection \"UnusedProperty\"\nenum.country.gh=Гана\n# suppress inspection \"UnusedProperty\"\nenum.country.gi=Гибралтар\n# suppress inspection \"UnusedProperty\"\nenum.country.gr=Греция\n# suppress inspection \"UnusedProperty\"\nenum.country.gl=Гренландия\n# suppress inspection \"UnusedProperty\"\nenum.country.gd=Гренада\n# suppress inspection \"UnusedProperty\"\nenum.country.gp=Гваделупа\n# suppress inspection \"UnusedProperty\"\nenum.country.gu=Гуам\n# suppress inspection \"UnusedProperty\"\nenum.country.gt=Гватемала\n# suppress inspection \"UnusedProperty\"\nenum.country.gg=Гернси\n# suppress inspection \"UnusedProperty\"\nenum.country.gn=Гвинея\n# suppress inspection \"UnusedProperty\"\nenum.country.gw=Гвинея-Бисау\n# suppress inspection \"UnusedProperty\"\nenum.country.gy=Гайана\n# suppress inspection \"UnusedProperty\"\nenum.country.ht=Гаити\n# suppress inspection \"UnusedProperty\"\nenum.country.hm=Остров Херд и острова Макдональд\n# suppress inspection \"UnusedProperty\"\nenum.country.va=Святой Престол (Ватикан)\n# suppress inspection \"UnusedProperty\"\nenum.country.hn=Гондурас\n# suppress inspection \"UnusedProperty\"\nenum.country.hk=Гонконг\n# suppress inspection \"UnusedProperty\"\nenum.country.hu=Венгрия\n# suppress inspection \"UnusedProperty\"\nenum.country.is=Исландия\n# suppress inspection \"UnusedProperty\"\nenum.country.in=Индия\n# suppress inspection \"UnusedProperty\"\nenum.country.id=Индонезия\n# suppress inspection \"UnusedProperty\"\nenum.country.ir=Иран\n# suppress inspection \"UnusedProperty\"\nenum.country.iq=Ирак\n# suppress inspection \"UnusedProperty\"\nenum.country.ie=Ирландия\n# suppress inspection \"UnusedProperty\"\nenum.country.im=Остров Мэн\n# suppress inspection \"UnusedProperty\"\nenum.country.il=Израиль\n# suppress inspection \"UnusedProperty\"\nenum.country.it=Италия\n# suppress inspection \"UnusedProperty\"\nenum.country.jm=Ямайка\n# suppress inspection \"UnusedProperty\"\nenum.country.jp=Япония\n# suppress inspection \"UnusedProperty\"\nenum.country.je=Джерси\n# suppress inspection \"UnusedProperty\"\nenum.country.jo=Иордания\n# suppress inspection \"UnusedProperty\"\nenum.country.kz=Казахстан\n# suppress inspection \"UnusedProperty\"\nenum.country.ke=Кения\n# suppress inspection \"UnusedProperty\"\nenum.country.ki=Кирибати\n# suppress inspection \"UnusedProperty\"\nenum.country.kp=Корейская Народно-Демократическая Республика\n# suppress inspection \"UnusedProperty\"\nenum.country.kr=Республика Корея\n# suppress inspection \"UnusedProperty\"\nenum.country.kw=Кувейт\n# suppress inspection \"UnusedProperty\"\nenum.country.kg=Кыргызстан\n# suppress inspection \"UnusedProperty\"\nenum.country.la=Лаос\n# suppress inspection \"UnusedProperty\"\nenum.country.lv=Латвия\n# suppress inspection \"UnusedProperty\"\nenum.country.lb=Ливан\n# suppress inspection \"UnusedProperty\"\nenum.country.ls=Лесото\n# suppress inspection \"UnusedProperty\"\nenum.country.lr=Либерия\n# suppress inspection \"UnusedProperty\"\nenum.country.ly=Ливия\n# suppress inspection \"UnusedProperty\"\nenum.country.li=Лихтенштейн\n# suppress inspection \"UnusedProperty\"\nenum.country.lt=Литва\n# suppress inspection \"UnusedProperty\"\nenum.country.lu=Люксембург\n# suppress inspection \"UnusedProperty\"\nenum.country.mo=Макао\n# suppress inspection \"UnusedProperty\"\nenum.country.mk=Северная Македония\n# suppress inspection \"UnusedProperty\"\nenum.country.mg=Мадагаскар\n# suppress inspection \"UnusedProperty\"\nenum.country.mw=Малави\n# suppress inspection \"UnusedProperty\"\nenum.country.my=Малайзия\n# suppress inspection \"UnusedProperty\"\nenum.country.mv=Мальдивы\n# suppress inspection \"UnusedProperty\"\nenum.country.ml=Мали\n# suppress inspection \"UnusedProperty\"\nenum.country.mt=Мальта\n# suppress inspection \"UnusedProperty\"\nenum.country.mh=Маршалловы Острова\n# suppress inspection \"UnusedProperty\"\nenum.country.mq=Мартиника\n# suppress inspection \"UnusedProperty\"\nenum.country.mr=Мавритания\n# suppress inspection \"UnusedProperty\"\nenum.country.mu=Маврикий\n# suppress inspection \"UnusedProperty\"\nenum.country.yt=Майотта\n# suppress inspection \"UnusedProperty\"\nenum.country.mx=Мексика\n# suppress inspection \"UnusedProperty\"\nenum.country.fm=Микронезия\n# suppress inspection \"UnusedProperty\"\nenum.country.md=Молдова\n# suppress inspection \"UnusedProperty\"\nenum.country.mc=Монако\n# suppress inspection \"UnusedProperty\"\nenum.country.mn=Монголия\n# suppress inspection \"UnusedProperty\"\nenum.country.me=Черногория\n# suppress inspection \"UnusedProperty\"\nenum.country.ms=Монтсеррат\n# suppress inspection \"UnusedProperty\"\nenum.country.ma=Марокко\n# suppress inspection \"UnusedProperty\"\nenum.country.mz=Мозамбик\n# suppress inspection \"UnusedProperty\"\nenum.country.mm=Мьянма\n# suppress inspection \"UnusedProperty\"\nenum.country.na=Намибия\n# suppress inspection \"UnusedProperty\"\nenum.country.nr=Науру\n# suppress inspection \"UnusedProperty\"\nenum.country.np=Непал\n# suppress inspection \"UnusedProperty\"\nenum.country.nl=Нидерланды\n# suppress inspection \"UnusedProperty\"\nenum.country.an=Нидерландские Антильские острова\n# suppress inspection \"UnusedProperty\"\nenum.country.nc=Новая Каледония\n# suppress inspection \"UnusedProperty\"\nenum.country.nz=Новая Зеландия\n# suppress inspection \"UnusedProperty\"\nenum.country.ni=Никарагуа\n# suppress inspection \"UnusedProperty\"\nenum.country.ne=Нигер\n# suppress inspection \"UnusedProperty\"\nenum.country.ng=Нигерия\n# suppress inspection \"UnusedProperty\"\nenum.country.nu=Ниуэ\n# suppress inspection \"UnusedProperty\"\nenum.country.nf=Остров Норфолк\n# suppress inspection \"UnusedProperty\"\nenum.country.mp=Северные Марианские острова\n# suppress inspection \"UnusedProperty\"\nenum.country.no=Норвегия\n# suppress inspection \"UnusedProperty\"\nenum.country.om=Оман\n# suppress inspection \"UnusedProperty\"\nenum.country.pk=Пакистан\n# suppress inspection \"UnusedProperty\"\nenum.country.pw=Палау\n# suppress inspection \"UnusedProperty\"\nenum.country.ps=Палестина\n# suppress inspection \"UnusedProperty\"\nenum.country.pa=Панама\n# suppress inspection \"UnusedProperty\"\nenum.country.pg=Папуа — Новая Гвинея\n# suppress inspection \"UnusedProperty\"\nenum.country.py=Парагвай\n# suppress inspection \"UnusedProperty\"\nenum.country.pe=Перу\n# suppress inspection \"UnusedProperty\"\nenum.country.ph=Филиппины\n# suppress inspection \"UnusedProperty\"\nenum.country.pn=Острова Питкэрн\n# suppress inspection \"UnusedProperty\"\nenum.country.pl=Польша\n# suppress inspection \"UnusedProperty\"\nenum.country.pt=Португалия\n# suppress inspection \"UnusedProperty\"\nenum.country.pr=Пуэрто-Рико\n# suppress inspection \"UnusedProperty\"\nenum.country.qa=Катар\n# suppress inspection \"UnusedProperty\"\nenum.country.re=Реюньон\n# suppress inspection \"UnusedProperty\"\nenum.country.ro=Румыния\n# suppress inspection \"UnusedProperty\"\nenum.country.ru=Россия\n# suppress inspection \"UnusedProperty\"\nenum.country.rw=Руанда\n# suppress inspection \"UnusedProperty\"\nenum.country.sh=Острова Святой Елены, Вознесения и Тристан-да-Кунья\n# suppress inspection \"UnusedProperty\"\nenum.country.kn=Сент-Китс и Невис\n# suppress inspection \"UnusedProperty\"\nenum.country.lc=Сент-Люсия\n# suppress inspection \"UnusedProperty\"\nenum.country.pm=Сен-Пьер и Микелон\n# suppress inspection \"UnusedProperty\"\nenum.country.vc=Сент-Винсент и Гренадины\n# suppress inspection \"UnusedProperty\"\nenum.country.ws=Самоа\n# suppress inspection \"UnusedProperty\"\nenum.country.sm=Сан-Марино\n# suppress inspection \"UnusedProperty\"\nenum.country.st=Сан-Томе и Принсипи\n# suppress inspection \"UnusedProperty\"\nenum.country.sa=Саудовская Аравия\n# suppress inspection \"UnusedProperty\"\nenum.country.sn=Сенегал\n# suppress inspection \"UnusedProperty\"\nenum.country.rs=Сербия\n# suppress inspection \"UnusedProperty\"\nenum.country.sc=Сейшельские Острова\n# suppress inspection \"UnusedProperty\"\nenum.country.sl=Сьерра-Леоне\n# suppress inspection \"UnusedProperty\"\nenum.country.sg=Сингапур\n# suppress inspection \"UnusedProperty\"\nenum.country.sk=Словакия\n# suppress inspection \"UnusedProperty\"\nenum.country.si=Словения\n# suppress inspection \"UnusedProperty\"\nenum.country.sb=Соломоновы Острова\n# suppress inspection \"UnusedProperty\"\nenum.country.so=Сомали\n# suppress inspection \"UnusedProperty\"\nenum.country.za=Южная Африка\n# suppress inspection \"UnusedProperty\"\nenum.country.gs=Южная Георгия и Южные Сандвичевы острова\n# suppress inspection \"UnusedProperty\"\nenum.country.ss=Южный Судан\n# suppress inspection \"UnusedProperty\"\nenum.country.es=Испания\n# suppress inspection \"UnusedProperty\"\nenum.country.lk=Шри-Ланка\n# suppress inspection \"UnusedProperty\"\nenum.country.sd=Судан\n# suppress inspection \"UnusedProperty\"\nenum.country.sr=Суринам\n# suppress inspection \"UnusedProperty\"\nenum.country.sj=Шпицберген и Ян-Майен\n# suppress inspection \"UnusedProperty\"\nenum.country.sz=Эсватини\n# suppress inspection \"UnusedProperty\"\nenum.country.se=Швеция\n# suppress inspection \"UnusedProperty\"\nenum.country.ch=Швейцария\n# suppress inspection \"UnusedProperty\"\nenum.country.sy=Сирия\n# suppress inspection \"UnusedProperty\"\nenum.country.tw=Тайвань\n# suppress inspection \"UnusedProperty\"\nenum.country.tj=Таджикистан\n# suppress inspection \"UnusedProperty\"\nenum.country.tz=Танзания\n# suppress inspection \"UnusedProperty\"\nenum.country.th=Таиланд\n# suppress inspection \"UnusedProperty\"\nenum.country.tl=Восточный Тимор\n# suppress inspection \"UnusedProperty\"\nenum.country.tg=Того\n# suppress inspection \"UnusedProperty\"\nenum.country.tk=Токелау\n# suppress inspection \"UnusedProperty\"\nenum.country.to=Тонга\n# suppress inspection \"UnusedProperty\"\nenum.country.tt=Тринидад и Тобаго\n# suppress inspection \"UnusedProperty\"\nenum.country.tn=Тунис\n# suppress inspection \"UnusedProperty\"\nenum.country.tr=Турция\n# suppress inspection \"UnusedProperty\"\nenum.country.tm=Туркменистан\n# suppress inspection \"UnusedProperty\"\nenum.country.tc=Острова Теркс и Кайкос\n# suppress inspection \"UnusedProperty\"\nenum.country.tv=Тувалу\n# suppress inspection \"UnusedProperty\"\nenum.country.ug=Уганда\n# suppress inspection \"UnusedProperty\"\nenum.country.ua=Украина\n# suppress inspection \"UnusedProperty\"\nenum.country.ae=Объединенные Арабские Эмираты\n# suppress inspection \"UnusedProperty\"\nenum.country.gb=Великобритания\n# suppress inspection \"UnusedProperty\"\nenum.country.us=Соединенные Штаты\n# suppress inspection \"UnusedProperty\"\nenum.country.um=Внешние малые острова США\n# suppress inspection \"UnusedProperty\"\nenum.country.uy=Уругвай\n# suppress inspection \"UnusedProperty\"\nenum.country.uz=Узбекистан\n# suppress inspection \"UnusedProperty\"\nenum.country.vu=Вануату\n# suppress inspection \"UnusedProperty\"\nenum.country.ve=Венесуэла\n# suppress inspection \"UnusedProperty\"\nenum.country.vn=Вьетнам\n# suppress inspection \"UnusedProperty\"\nenum.country.vg=Виргинские острова (Британские)\n# suppress inspection \"UnusedProperty\"\nenum.country.vi=Виргинские острова (США)\n# suppress inspection \"UnusedProperty\"\nenum.country.wf=Уоллис и Футуна\n# suppress inspection \"UnusedProperty\"\nenum.country.eh=Западная Сахара\n# suppress inspection \"UnusedProperty\"\nenum.country.ye=Йемен\n# suppress inspection \"UnusedProperty\"\nenum.country.zm=Замбия\n# suppress inspection \"UnusedProperty\"\nenum.country.zw=Зимбабве\n# suppress inspection \"UnusedProperty\"\nenum.country.tor=Tor\n# suppress inspection \"UnusedProperty\"\nenum.country.i2p=I2P\n# suppress inspection \"UnusedProperty\"\nenum.country.lan=Локальная сеть\n"
  },
  {
    "path": "common/src/main/resources/i18n/messages_zh.properties",
    "content": "#\n# Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n\n# Common\n\nok=确定\ncancel=取消\nclose=关闭\nsend=发送\ncreate=创建\nremove=移除\ndownload=下载\nadd=添加\nopen=打开\ncopy-link=复制链接地址\ncopy=复制\nsave-as=另存为...\npaste-id=粘贴自己的ID\nundo=撤销\nredo=重做\ncut=剪切\npaste=粘贴\ndelete=删除\nselect-all=全选\ndeselect-all=取消全选\nview-fullscreen=全屏查看\ncopy-image=复制图片\nsave-image-as=另存图片为...\nenabled=已启用\nno-results=未找到结果\nskip=跳过\nname=名称\nhelp=帮助\nsettings=设置\nexit=退出\nprofile=个人资料\nsubscribed=已订阅\nown=自己的\ndescription=描述\nsubject=主题\nhash=哈希值\nsize=大小\ntrust=信任\nunknown-lc=未知\nlogo=标志\nlatest=最新\nupdate=更新\nedit=编辑\nbody=正文（可选）\ntext=文本\nimage=图片\nlink=链接\nmark-read-unread=标记为已读/未读\nthumbnail=缩略图\nposts-at-remote-nodes=远程节点上的帖子\nlast-activity=最近活动\nstate=状态\nip=IP\nport=端口\n\n# File Requesters\n\nfile-requester.profiles=个人资料文件\nfile-requester.xml=XML文件\nfile-requester.png=PNG文件\nfile-requester.sounds=声音文件\nfile-requester.select-sound-title=选择声音文件\nfile-requester.images=图像文件\nfile-requester.save-image-title=选择保存图像的位置\nfile-requester.error=文件 {0} 错误：{1}\nfile-requester.add-files=选择要添加的文件\n\n# Main\n\n## Menu\n\nmain.menu.add-peer=添加节点...\nmain.menu.broadcast=广播...\nmain.menu.shares=配置共享\nmain.menu.statistics=显示统计信息\n\nmain.menu.tools=工具\nmain.menu.tools.import-from-rs=从Retroshare导入好友...\nmain.menu.tools.export=导出...\n\nmain.menu.help.about=关于Xeres\nmain.menu.help.documentation=文档\nmain.menu.help.report-bug=报告错误 ↗\nmain.menu.help.check-for-updates=检查更新... ↗\n\nmain.friends-import-successful=成功导入 {0} 个位置。\nmain.friends-import-errors=导入了 {0} 个位置，但 {1} 个有错误。\n\nmain.systray.peers=已连接 {0,number,integer} 个节点\n\n## Splash\n\nsplash.status.database=正在加载数据库\nsplash.status.network=正在启动网络\n\n## Content\n\nmain.home=主页\nmain.contacts=联系人\nmain.chats=聊天\nmain.forums=论坛\nmain.files=文件\nmain.boards=留言板\nmain.channels=频道\n\nmain.home.slogan=友谊与自由交汇之地\nmain.home.share-id=这是您的Xeres ID。请将其分享给其他人。\nmain.home.received-id=您是否收到了来自节点的ID？\nmain.home.add-peer=添加节点\nmain.home.add-peer.tip=通过粘贴其ID来添加好友\nmain.home.need-help=需要帮助？\nmain.home.online-help=在线帮助 ↗\nmain.home.online-help.tip=显示在线帮助 (Ctrl+F1)\nmain.home.copy-id.tip=将Xeres ID复制到剪贴板\nmain.home.qrcode.tip=使用QR码传输您的ID。将其打印或用手机拍照，然后显示给网络摄像头。\n\nmain.select-avatar=选择头像图片\nmain.export-profile=选择保存个人资料的位置\nmain.import-friends=选择Retroshare好友文件\n\nmain.scanning=正在扫描 {0}...\nmain.hashing=正在计算 {0} 的哈希值\nmain.scanning.tip=共享：{0}，文件：{1}\n\n## Status\n\nmain.status.connections=连接数：\nmain.status.nat.unknown=状态尚不可知。\nmain.status.nat.firewalled=客户端无法从互联网发起的连接访问。\nmain.status.nat.upnp=UPNP已激活，客户端可从互联网完全访问。\nmain.status.dht.disabled=DHT已禁用。\nmain.status.dht.initializing=DHT正在初始化。这可能需要一些时间。\nmain.status.dht.running=DHT工作正常，客户端的IP地址已向其节点公布。\nmain.status.dht.stats=节点数：{0,number,integer}\\n接收数据包：{1,number,integer} ({2})\\n发送数据包：{3,number,integer} ({4})\\n密钥数：{5,number,integer}\\n项目数：{6,number,integer}\n\nmain.exit.confirm=确定要退出Xeres吗？\n\n# Account creation\n\naccount.welcome=欢迎来到Xeres\naccount.welcome.tip=您需要创建一个个人资料和一个位置。\\n\\n个人资料代表您自己，可以使用您的姓名或昵称，而位置是您当前使用的设备。\\n\\n您可以拥有多个位置，例如桌面电脑和笔记本电脑，它们都使用同一个个人资料（即您）。\\n\\n使用导入选项来导入您之前已创建的个人资料。\\n\\n所有内容始终存储在本地，因此请不要忘记备份您的数据。\\n\\n按F1键阅读内置文档，并记住将鼠标指针短暂悬停在用户界面元素上会显示其说明。\naccount.profile.prompt=个人资料名称\naccount.profile.tip=使用昵称或真实姓名。一个个人资料可以有多个位置。\naccount.location=位置\naccount.location.prompt=位置名称\naccount.location.tip=这是您在此设备上的Xeres实例。使用您设备的昵称或型号。\naccount.options=选项\naccount.generation.profile-keys=正在生成个人资料密钥...\naccount.generation.location-keys-and-certificate=正在生成位置密钥和证书...\naccount.generation.identity=正在生成身份...\naccount.generation.profile-load=选择Xeres个人资料文件 (xeres_backup.xml)、Retroshare密钥环 (retroshare_secret_keyring.gpg) 或Retroshare个人资料 (*.asc)\naccount.generation.import=导入...\naccount.generation.import.tip=您可以导入3种类型的个人资料：\\n\\n从Xeres导出的个人资料 (xeres_backup.xml)。\\n\\nRetroshare密钥环 (retroshare_secret_keyring.gpg) 或从Retroshare导出的个人资料 (*.asc)。\naccount.generation.import.progress=正在导入个人资料...\naccount.generation.import.confirm.title=Retroshare导入器\naccount.generation.import.confirm.prompt=输入Retroshare密码\naccount.generation.import.unknown=未知的文件格式\n\n# Chat\n\n## Common\n\nchat.notification.typing={0} 正在输入\n\n## Room common\n\nchat.room.id=ID\nchat.room.topic=主题\nchat.room.security=安全性\nchat.room.users=用户数\n\nchat.room.info=主题：{0}\\n用户：{1,number,integer}\\n安全性：{2}\\nID：{3}\nchat.room.none=[无]\nchat.room.private=私密\nchat.room.public=公开\nchat.room.signed-only=仅限已签名的ID\nchat.room.anonymous-allowed=允许匿名ID\nchat.room.user-info=名称：{0}\\nID：{1}\nchat.room.user-menu=信息\nchat.room.clear-history=您确定要清除历史记录吗？\nchat.room.copy-selection=复制选中内容\nchat.room.clear-chat-history=清除聊天历史记录\n\n## Room create\n\nchat.room.create.window-title=创建聊天室\nchat.room.create.name.prompt=简短且描述性的房间名称\nchat.room.create.name.tip=房间的名称。请使用正确的大小写和空格。\nchat.room.create.topic.prompt=房间的主题\nchat.room.create.topic.tip=房间的描述，即其主题内容。\nchat.room.create.visibility=可见性\nchat.room.create.visibility.tip=公开房间对节点可见。\\n私密房间不可见，仅通过邀请加入。\nchat.room.create.security.checkbox=仅限已签名的身份\nchat.room.create.security.tip=限制为已签名身份的房间更能抵抗垃圾信息，因为匿名身份无法加入。\nchat.room.create.tooltip=创建一个新的聊天室\n\n## Room invite\n\nchat.room.invite.window-title=邀请节点加入当前聊天室\nchat.room.invite.button=邀请\nchat.room.invite.tip=邀请节点加入当前聊天室\nchat.room.invite.request={0} 想要邀请您加入 {1} ({2})\n\nchat.room.join=加入\nchat.room.leave=离开\n\nchat.room.not-found=未找到房间。可能是因为该房间在您任何已连接的好友处都不可用。\n\n# Forums\n\ngxs-group.tree.popular=热门\ngxs-group.tree.other=其他\n\ngxs-group.tree.info=名称：{0}\\nID：{1}\\n远程消息：{2}\\n远程活动：{3}\n\ngxs-group.tree.subscribe=订阅\ngxs-group.tree.unsubscribe=取消订阅\n\nforum.new-message.window-title=新消息\nforum.create.window-title=创建论坛\nforum.create.name.prompt=简短且描述性的论坛名称\nforum.create.name.tip=论坛的名称。请使用正确的大小写和空格。\nforum.create.description.prompt=论坛的主题\nforum.create.description.tip=论坛的描述，即其主题内容。\n\nforum.editor.name=论坛\nforum.editor.name.prompt=论坛名称\nforum.editor.thread.description=帖子主题\nforum.editor.cancel=论坛消息尚未发送！确定要放弃此消息吗？\n\nforum.view.create.tip=创建新论坛\nforum.view.header.author=作者\nforum.view.header.date=日期\n\nforum.view.new-message.tip=创建新消息\n\nforum.view.group.not-found=未找到论坛。可能是因为它在您任何已连接的好友处都不可用。\nforum.view.message.not-found=未找到消息。可能是因为消息太旧或发布者信誉过低。\n\nforum.view.from=来自：\nforum.view.subject=主题：\nforum.view.reply=回复\n\nforum.view.history=此选择器用于显示之前的消息版本。\n\n# Boards\n\nboard.create.window-title=创建留言板\nboard.create.name.prompt=简短且描述性的留言板名称\nboard.create.name.tip=留言板的名称。请使用正确的大小写和空格。\nboard.create.description.prompt=留言板的主题\nboard.create.description.tip=留言板的描述，即其主题内容。\nboard.select-logo=选择留言板图片\nboard.select-image=选择要发布的图片\n\nboard.view.create.tip=创建新留言板\nboard.view.group.not-found=未找到板块。这可能是因为您在已连接的好友中均未提供该板块。\n\nboard.new-message.window-title=新帖子\n\nboard.editor.name=留言板\nboard.editor.name.prompt=留言板名称\nboard.editor.thread.title=标题\nboard.editor.post.description=帖子标题\n\nboard.editor.cancel=留言板帖子尚未发送！确定要放弃此帖子吗？\n\nboard.posted-by=发布者\nboard.on=发布于\n\n# Channels\n\nchannel.view.create.tip=创建新频道\nchannel.create.window-title=创建频道\nchannel.create.name.prompt=频道的简短且描述性名称\nchannel.create.name.tip=频道名称。请使用适当的大小写和空格。\nchannel.create.description.prompt=频道的内容主题\nchannel.create.description.tip=频道的描述，说明其内容主题。\nchannel.select-image=为频道帖子选择图片\n\nchannel.view.group.not-found=未找到频道。这可能是因为您在已连接的好友中均未提供该频道。\n\nchannel.select-logo=选择频道图片\n\nchannel.new-message.window-title=新建频道帖子\n\nchannel.editor.name=频道\nchannel.editor.name.prompt=频道的名称\nchannel.editor.thread.title=标题\nchannel.editor.post.description=帖子标题\n\nchannel.editor.cancel=频道帖子尚未发送！您确定要放弃此帖子吗？\nchannel.clipboard.error=剪贴板中不包含文件链接。\nchannel.files=文件\nchannel.post=发布\nchannel.drag-drop=添加文件或拖放至此\nchannel.add-files=添加文件\nchannel.paste-links=粘贴链接\nchannel.remove-files=移除文件\n\n# Add RSID\n\nrs-id.add.window-title=添加节点\nrs-id.add.textarea.prompt=粘贴节点的ID\nrs-id.add.textarea.tip=ID是一串大约一百个base64字符。它编码了连接到一个节点所需的所有信息。\nrs-id.add.details=节点详细信息\nrs-id.add.name.tip=节点的名称，请确保您知道它是谁。\nrs-id.add.profile=个人资料ID\nrs-id.add.profile.tip=唯一ID，用于验证您节点的个人资料是否正确。\nrs-id.add.fingerprint=指纹\nrs-id.add.fingerprint.tip=密码学校验和，用于认证您节点个人资料的真实性。\nrs-id.add.location=位置ID\nrs-id.add.location.tip=位置标识符。一个个人资料可以有多个位置，每个都有唯一的ID。\nrs-id.add.addresses=地址\nrs-id.add.addresses.tip=用于连接的地址。将依次尝试所有这些地址，但您可以选择最佳地址以加快初始连接速度。\\n以 .onion 结尾的地址需要使用Tor代理。\\n以 .i2p 结尾的地址需要使用I2P代理。\nrs-id.add.trust.tip=您对该节点的信任级别。\\n未知：无意见。\\n从不：无或极低信任度，最近在线遇到。\\n边缘：较可信，熟人。\\n完全：非常可信，好朋友。\nrs-id.add.invalid=无效的ID\nrs-id.add.scan=使用摄像头扫描QR码。\n\n# Broadcast\n\nbroadcast.window-title=广播\nbroadcast.send.explanation=向所有当前连接的节点发送消息。\nbroadcast.send.warning-header=警告：\nbroadcast.send.warning=请勿滥用此功能。仅用于紧急情况或特殊情况。\n\n# Messaging\n\nmessaging.prompt=输入消息\nmessaging.file-requester.send-picture=选择要内联发送的图片\nmessaging.file-requester.send-file=选择要发送的文件\nmessaging.send-picture=选择要内联发送的图片\nmessaging.send-sticker=发送贴纸\nmessaging.send.file=选择要发送的文件\nmessaging.action.call=进行直接通话\nmessaging.action.send-inline=内联发送图片\nmessaging.action.send-file=发送文件\n\nmessaging.warning.title=警告\nmessaging.warning.description=用户当前离线，无法接收消息。\n\nmessaging.tunneling=正在尝试建立隧道...\n\nmessaging.closing-tunnel.confirm=关闭此窗口将结束远程聊天并丢弃所有未发送的消息。确定吗？\n\n# Profiles\n\nprofiles.delete=删除个人资料\n\n# About\n\nabout.window-title=关于 {0}\nabout.version=版本：\nabout.title=关于\nabout.slogan=一个点对点、去中心化、安全的通信与共享应用程序\nabout.authors=作者\nabout.author-by=由\nabout.all-rights-reserved=保留所有权利\nabout.report-bugs=报告错误或提出改进建议。\nabout.website=网站\nabout.wiki=维基\nabout.source-code=源代码\nabout.thanks=致谢\nabout.license=许可证\nabout.additional-licenses=附加许可证\nabout.release=发布\nabout.profiles=个人资料：\n\n# QR Code\n\nqr-code.window-title=QR码\nqr-code.print=打印...\nqr-code.save-as-png=另存为PNG\nqr-code.download-client=在 https://xeres.io 下载客户端\nqr-code.camera.error=未检测到摄像头\n\n# Camera\n\ncamera.window-title=扫描QR码\n\n# Settings\n\n## Main\n\nsettings.general=通用\nsettings.network=网络\nsettings.transfer=传输\nsettings.notifications=通知\nsettings.sound=声音\nsettings.remote=远程\nsettings.directory.no-remote=在远程模式下无法选择目录\n\n## General\n\nsettings.general.theme=主题\nsettings.general.system=系统\nsettings.general.startup=系统启动时启动\nsettings.general.startup.tip=系统启动时自动运行，最小化到托盘。\nsettings.general.startup.not-available=不可用。要么操作系统不支持，要么您正在便携模式下运行。\nsettings.general.update-check=自动检查更新\nsettings.general.update-check.tip=每天自动检查GitHub以查看是否有新版本。\n\n## Network\n\nsettings.network.hidden-services=隐藏服务\nsettings.network.tor-proxy=Tor Socks代理\nsettings.network.tor-proxy.prompt=Tor服务器\nsettings.network.tor-proxy.tip=Tor SOCKS v5 IP地址或主机名，如果在同一主机上运行，通常为127.0.0.1。\nsettings.network.tor-port.tip=Tor SOCKS v5端口，通常为9050。\nsettings.network.i2p-proxy=I2P Socks代理\nsettings.network.i2p-proxy.prompt=I2P服务器\nsettings.network.i2p-proxy.tip=I2P SOCKS v5 IP地址或主机名，如果在同一主机上运行，通常为127.0.0.1。\nsettings.network.i2p-port.tip=I2P SOCKS v5端口，通常为4447。\nsettings.network.use-upnp=使用UPNP\nsettings.network.use-upnp.tip=UPNP（通用即插即用）允许自动在路由器中设置正确的传入端口。这提高了来自您节点的连接可靠性。\nsettings.network.external-ip-and-port=外部IP和端口\nsettings.network.external-ip-and-port.tip=您位置的外部IP地址和端口。这是您在互联网上的连接方式。\nsettings.network.use-broadcast-discovery=启用广播发现\nsettings.network.use-broadcast-discovery.tip=广播发现允许将您的IP和端口告知您局域网上的其他位置。这提高了来自您局域网上可能节点的连接可靠性。\nsettings.network.internal-ip-and-port=内部IP和端口\nsettings.network.internal-ip-and-port.tip=您位置的内部IP地址和端口。这是您在局域网（LAN）上的连接方式。\nsettings.network.use-dht=启用DHT\nsettings.network.use-dht.tip=DHT（分布式哈希表）允许节点在IP地址更改时找到彼此。这提高了漫游时的连接性。\n\n## Remote\n\nsettings.remote.title=远程访问\nsettings.remote.username=用户名\nsettings.remote.password=密码\nsettings.remote.note=设置空密码将禁用身份验证。\nsettings.remote.enabled.tip=启用远程访问。然后可以从另一个Xeres实例或Android客户端访问此实例。\nsettings.remote.upnp-set=使用UPNP设置\nsettings.remote.upnp-set.tip=使用UPNP设置远程端口，使其可从广域网访问。\nsettings.remote.restart=您需要重新启动Xeres才能使远程访问更改生效。现在退出吗？\nsettings.remote.view-api=查看API\n\n## Transfer\n\nsettings.transfer.select-incoming=选择接收目录\nsettings.transfer.incoming=接收目录\n\n## Notifications\n\nsettings.notifications.show-connections=显示连接\nsettings.notifications.show-connections.tip=显示与好友建立连接时。\nsettings.notifications.show-broadcasts=显示广播\nsettings.notifications.show-broadcasts.tip=显示好友发送的广播消息。\nsettings.notifications.show-discovery=显示发现\nsettings.notifications.show-discovery.tip=显示启用了广播发现的客户端出现在局域网上时。\n\n## Sound\n\nsettings.sound.message=收到消息\nsettings.sound.message.tip=当收到私信且窗口处于非活动状态时播放声音。\nsettings.sound.highlight=被提及\nsettings.sound.highlight.tip=当有人在聊天室中提及您时播放声音。\nsettings.sound.friend=好友已连接\nsettings.sound.friend.tip=当好友连接到您时播放声音。\nsettings.sound.download=下载完成\nsettings.sound.download.tip=当下载完成时播放声音。\nsettings.sound.ringing=通话\nsettings.sound.ringing.tip=当收到或拨打电话时播放声音。\n\n# Share\n\nshare.window-title=共享\nshare.select-directory=选择要共享的目录\nshare.remove=移除共享\nshare.error.empty-name=共享名称不能为空。请设置一个唯一的名称。\nshare.error.empty-path=共享路径不能为空。请设置一个共享路径。\nshare.error.not-unique=共享名称已存在。每个共享名称必须是唯一的。\n\nshare.list.directory=共享目录\nshare.list.visible-name=可见名称\nshare.list.searchable=可搜索\nshare.list.browsable=可浏览\n\nshare.create=创建新共享\nshare.apply=应用并关闭\n\n# Tray\n\ntray.open=打开 {0}\ntray.peers=节点\ntray.status=状态\n\n# EditorView\n\neditor.hyperlink.enter=输入URL\neditor.action.undo=撤销 (Ctrl+Z)\neditor.action.redo=重做 (Ctrl+Shift+Z)\neditor.action.bold=粗体 (Ctrl+B)\neditor.action.italic=斜体 (Ctrl+I)\neditor.action.hyperlink=链接 (Ctrl+L)\neditor.action.quote=引用 (Ctrl+Q)\neditor.action.code=代码 (Ctrl+K)\neditor.action.unordered-list=无序列表 (Ctrl+U)\neditor.action.ordered-list=有序列表 (Ctrl+Shift+U)\neditor.action.header=标题 (Ctrl+1)\neditor.action.preview=预览消息 (F12)\n\n# Search / Download / Uploads\n\nsearch.main.search=搜索\nsearch.main.downloads=下载\nsearch.main.uploads=上传\nsearch.main.trends=趋势\n\nsearch.input.prompt=输入搜索词\nsearch.input.search.tip=输入多个搜索词将搜索包含所有这些词的文件。使用引号包围搜索词进行精确匹配。\nsearch.searching=正在搜索...\n\ntrends.none=尚无趋势\ntrends.list.terms=搜索词\ntrends.list.from=来自\ntrends.list.time=时间\n\ndownload-view.list.none=无下载\ndownload-view.list.state=状态\ndownload-view.list.progress=进度\ndownload-view.list.total-size=总大小\n\ndownload-view.show-in-folder=在文件夹中显示\n\ndownload-view.open-error=打开失败：\ndownload-view.show-error=在资源管理器中显示失败：\n\ndownload-add.window-title=添加下载\ndownload-add.bytes={0,number,integer} 字节\n\nupload-view.none=无正在上传的文件\n\nfile-result.column.type=类型\n\n# StatisticsTurtle\n\nstatistics.window-title=统计信息\n\nstatistics.elapsed-time=运行时间（秒）\n\nstatistics.turtle.data-in=数据输入\nstatistics.turtle.data-in.tip=接收的内容数据（下载）\nstatistics.turtle.data-out=数据输出\nstatistics.turtle.data-out.tip=发送的内容数据（上传）\nstatistics.turtle.data-forward=数据转发\nstatistics.turtle.data-forward.tip=转发给其他节点的内容数据\nstatistics.turtle.tunnel-in=隧道请求输入\nstatistics.turtle.tunnel-in.tip=传入的隧道请求\nstatistics.turtle.tunnel-out=隧道请求输出\nstatistics.turtle.tunnel-out.tip=转发的隧道请求和自身请求\nstatistics.turtle.search-in=搜索请求输入\nstatistics.turtle.search-in.tip=传入的搜索请求\nstatistics.turtle.search-out=搜索请求输出\nstatistics.turtle.search-out.tip=转发的搜索请求和自身请求\nstatistics.turtle.bandwidth=带宽\nstatistics.turtle.speed=速度 (KB/s)\nstatistics.turtle.tip=图表显示turtle路由器统计信息。包括：\\n搜索请求：文件搜索（标题、大小等）。\\n隧道请求：远程节点之间的隧道设置，为文件传输做准备。\\n数据请求：在隧道内流动的数据。\\n大多数不针对我们自身节点的请求和数据都会以一定的概率转发给其他节点，该概率随距离而变化。\n\nstatistics.rtt.rtt=往返时间\nstatistics.rtt.time=RTT (毫秒)\nstatistics.rtt.tip=RTT（往返时间）是指消息发送到目的地并收到回复所需的时间。它反映了节点之间的网络延迟。\\n如果RTT过高（超过几秒），可能会出现连接问题。\n\nstatistics.data-counter.title=数据使用量\nstatistics.data-counter.data=数据 (KB)\nstatistics.data-counter.tip=此图表显示与节点之间传入和传出的数据量。\nstatistics.data-counter.peers=节点数\n\nstatistics.turtle=乌龟\nstatistics.rtt=RTT\nstatistics.data-usage=数据使用量\n\n# ContactView\n\ncontact-view.profile-delete.confirm=这将移除并断开您与个人资料 {0} 的连接。确定吗？\ncontact-view.avatar-delete.confirm=确定要移除您的头像图片吗？\ncontact-view.location.last-connected.now=现在\ncontact-view.location.last-connected.never=从未\ncontact-view.information.linked-to-profile=身份链接到的个人资料\ncontact-view.information.profile=个人资料\ncontact-view.information.identity=身份\ncontact-view.information.type=类型\ncontact-view.information.created=创建于\ncontact-view.information.updated=更新于\ncontact-view.information.created-unknown=未知\ncontact-view.information.key-information-with-length=版本：{0}\\n算法：{1}\\n长度：{2} 位\\n签名哈希：{3}\ncontact-view.information.key-information=版本：{0}\\n算法：{1}\\n签名哈希：{2}\ncontact-view.open.identity-not-found=未找到身份\ncontact-view.open.profile-not-found=未找到个人资料\ncontact-view.information.location.id=位置ID：\ncontact-view.information.location.version=版本：\ncontact-view.search.prompt=搜索联系人\ncontact-view.search.show-all=显示所有联系人\ncontact-view.search.no-contacts=无联系人\ncontact-view.badge.own=自己的\ncontact-view.badge.own.tip=这是您自己。\ncontact-view.badge.partial=部分的\ncontact-view.badge.partial.tip=部分联系人尚未有完整的个人资料支持。需要至少连接一次，然后会进行检查，如果成功，将升级为完整的个人资料。\ncontact-view.badge.accepted=已接受\ncontact-view.badge.accepted.tip=此联系人已被接受用于传入连接，并且也会尝试与其建立传出连接。\ncontact-view.badge.not-validated=尚未验证\ncontact-view.badge.not-validated.tip=联系人尚未验证。将很快验证其个人资料签名，如果成功，将标记为有效。如果失败，将被删除（但可能会再次传输，如果是，请尝试通知其所有者此问题）。\ncontact-view.action.chat=聊天\ncontact-view.action.distant-chat=远程聊天\ncontact-view.action.connect=尝试连接\ncontact-view.information.locations=位置\ncontact-view.column.last-connected=上次连接\ncontact-view.chat.start=开始直接聊天\ncontact-view.distant-chat.start=开始远程聊天\n\n# ImageSelectorView\n\nimage-selector-view.change-image=更改图片...\nimage-selector-view.change-image-short=更改\nimage-selector-view.add-image=添加图片...\n\n# VoIP\n\nvoip.window-title=通话\nvoip.action.message=消息\nvoip.action.message.tip=向用户发送直接聊天消息\nvoip.action.recall=再次呼叫\nvoip.action.recall.tip=回拨用户\nvoip.action.close.tip=关闭窗口\nvoip.action.answer=接听\nvoip.action.reject=拒绝\nvoip.action.hangup=挂断\nvoip.action.window-quit=确定要中止通话吗？\nvoip.status.incoming=来电...\nvoip.status.calling=正在呼叫...\nvoip.status.ongoing=通话中\nvoip.status.ended=通话结束\n\n# Update\n\nupdate.latest-already=您已安装最新版本。\nupdate.new-version=有新版本可用 ({0})。是否下载、验证并安装？\nupdate.new-version-auto=有新版本可用 ({0})。\nupdate.download-failure=无法下载URL和/或签名URL\nupdate.download-file=正在下载文件...\nupdate.download.title=Xeres更新程序\nupdate.download.verifying=正在验证文件...\nupdate.download.install=安装\nupdate.download.install-ready=准备就绪，可以安装！\nupdate.download-verification-failed=验证失败！\n\n# Stickers\n\nstickers.instructions=将您的贴纸添加到 {0}\\n\\n每个贴纸集合一个目录，每个目录包含PNG或JPEG文件。\n\n# ChatCommands\n\nchat-command.code=将文本作为代码块发送\nchat-command.coin=抛硬币\nchat-command.me=以第三人称发送动作消息\nchat-command.pre=将文本作为预格式化文本发送\nchat-command.quote=将文本作为引用发送\nchat-command.random=发送1到10之间的随机数\nchat-command-send=发送 {0}\n\n# Misc\n\nuri.malicious-link=警告！这是恶意链接，点击将跳转到：{0}\nuri.unsafe-link=警告！此链接可能不安全，点击后将进入：{0}\nuri.malicious-link.confirm=警告！这是恶意链接，将跳转到 {0}。确定要继续吗？\nuri.unsafe-link.confirm=警告！此链接可能不安全，点击后将进入 {0}。您了解这是什么链接并且信任它吗？\n\ncontent-image.exit=按ESC键或点击退出\n\nwebsocket.disconnected=WebSocket连接已断开。聊天不可用。重新连接？\n\n# TrustConverter\n\ntrust-converter.nobody=无人\ntrust-converter.everybody=所有人\ntrust-converter.marginal=边缘信任人\ntrust-converter.full=完全信任人\ntrust-converter.ultimate=仅我自己\n\n# Byte units\n\nbyte-unit.invalid=无效\nbyte-unit.bytes=字节\nbyte-unit.kb=KB\nbyte-unit.mb=MB\nbyte-unit.gb=GB\nbyte-unit.tb=TB\nbyte-unit.pb=PB\nbyte-unit.eb=EB\n\n# Help\n\nhelp.back=在历史记录中后退\nhelp.forward=在历史记录中前进\nhelp.home=转到首页部分\n\n# Enums (beware of the key naming which must be the same as the class!)\n\n## Trust\n\n# suppress inspection \"UnusedProperty\"\nenum.trust.unknown=未知\n# suppress inspection \"UnusedProperty\"\nenum.trust.never=从不\n# suppress inspection \"UnusedProperty\"\nenum.trust.marginal=边缘\n# suppress inspection \"UnusedProperty\"\nenum.trust.full=完全\n# suppress inspection \"UnusedProperty\"\nenum.trust.ultimate=绝对\n\n## Availability\n\n# suppress inspection \"UnusedProperty\"\nenum.availability.available=在线\n# suppress inspection \"UnusedProperty\"\nenum.availability.busy=忙碌\n# suppress inspection \"UnusedProperty\"\nenum.availability.away=离开\n# suppress inspection \"UnusedProperty\"\nenum.availability.offline=离线\n\n## RoomType\n\n# suppress inspection \"UnusedProperty\"\nenum.room-type.private=私密\n# suppress inspection \"UnusedProperty\"\nenum.room-type.public=公开\n\n## FileType\n\n# suppress inspection \"UnusedProperty\"\nenum.file-type.any=任意\n# suppress inspection \"UnusedProperty\"\nenum.file-type.audio=音频\n# suppress inspection \"UnusedProperty\"\nenum.file-type.archive=压缩包\n# suppress inspection \"UnusedProperty\"\nenum.file-type.document=文档\n# suppress inspection \"UnusedProperty\"\nenum.file-type.picture=图片\n# suppress inspection \"UnusedProperty\"\nenum.file-type.program=程序\n# suppress inspection \"UnusedProperty\"\nenum.file-type.video=视频\n# suppress inspection \"UnusedProperty\"\nenum.file-type.subtitles=字幕\n# suppress inspection \"UnusedProperty\"\nenum.file-type.collection=集合\n# suppress inspection \"UnusedProperty\"\nenum.file-type.directory=目录\n\n## FileProgressDisplay State\n\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.searching=搜索中\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.transferring=传输中\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.removing=移除中\n# suppress inspection \"UnusedProperty\"\nenum.file-progress-display.state.done=完成\n\n## FileAttachment State\n\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.hashing=正在哈希\n# suppress inspection \"UnusedProperty\"\nenum.channel-file.state.done=已完成\n\n## Country\n\n# suppress inspection \"UnusedProperty\"\nenum.country.af=阿富汗\n# suppress inspection \"UnusedProperty\"\nenum.country.al=阿尔巴尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.dz=阿尔及利亚\n# suppress inspection \"UnusedProperty\"\nenum.country.as=美属萨摩亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ad=安道尔\n# suppress inspection \"UnusedProperty\"\nenum.country.ao=安哥拉\n# suppress inspection \"UnusedProperty\"\nenum.country.ai=安圭拉\n# suppress inspection \"UnusedProperty\"\nenum.country.aq=南极洲\n# suppress inspection \"UnusedProperty\"\nenum.country.ag=安提瓜和巴布达\n# suppress inspection \"UnusedProperty\"\nenum.country.ar=阿根廷\n# suppress inspection \"UnusedProperty\"\nenum.country.am=亚美尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.aw=阿鲁巴\n# suppress inspection \"UnusedProperty\"\nenum.country.au=澳大利亚\n# suppress inspection \"UnusedProperty\"\nenum.country.at=奥地利\n# suppress inspection \"UnusedProperty\"\nenum.country.az=阿塞拜疆\n# suppress inspection \"UnusedProperty\"\nenum.country.bs=巴哈马\n# suppress inspection \"UnusedProperty\"\nenum.country.bh=巴林\n# suppress inspection \"UnusedProperty\"\nenum.country.bd=孟加拉国\n# suppress inspection \"UnusedProperty\"\nenum.country.bb=巴巴多斯\n# suppress inspection \"UnusedProperty\"\nenum.country.by=白俄罗斯\n# suppress inspection \"UnusedProperty\"\nenum.country.be=比利时\n# suppress inspection \"UnusedProperty\"\nenum.country.bz=伯利兹\n# suppress inspection \"UnusedProperty\"\nenum.country.bj=贝宁\n# suppress inspection \"UnusedProperty\"\nenum.country.bm=百慕大\n# suppress inspection \"UnusedProperty\"\nenum.country.bt=不丹\n# suppress inspection \"UnusedProperty\"\nenum.country.bo=玻利维亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ba=波斯尼亚和黑塞哥维那\n# suppress inspection \"UnusedProperty\"\nenum.country.bw=博茨瓦纳\n# suppress inspection \"UnusedProperty\"\nenum.country.bv=布韦岛\n# suppress inspection \"UnusedProperty\"\nenum.country.br=巴西\n# suppress inspection \"UnusedProperty\"\nenum.country.io=英属印度洋领地\n# suppress inspection \"UnusedProperty\"\nenum.country.bn=文莱\n# suppress inspection \"UnusedProperty\"\nenum.country.bg=保加利亚\n# suppress inspection \"UnusedProperty\"\nenum.country.bf=布基纳法索\n# suppress inspection \"UnusedProperty\"\nenum.country.bi=布隆迪\n# suppress inspection \"UnusedProperty\"\nenum.country.kh=柬埔寨\n# suppress inspection \"UnusedProperty\"\nenum.country.cm=喀麦隆\n# suppress inspection \"UnusedProperty\"\nenum.country.ca=加拿大\n# suppress inspection \"UnusedProperty\"\nenum.country.cv=佛得角\n# suppress inspection \"UnusedProperty\"\nenum.country.ky=开曼群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.cf=中非共和国\n# suppress inspection \"UnusedProperty\"\nenum.country.td=乍得\n# suppress inspection \"UnusedProperty\"\nenum.country.cl=智利\n# suppress inspection \"UnusedProperty\"\nenum.country.cn=中国\n# suppress inspection \"UnusedProperty\"\nenum.country.cx=圣诞岛\n# suppress inspection \"UnusedProperty\"\nenum.country.cc=科科斯（基林）群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.co=哥伦比亚\n# suppress inspection \"UnusedProperty\"\nenum.country.km=科摩罗\n# suppress inspection \"UnusedProperty\"\nenum.country.cg=刚果\n# suppress inspection \"UnusedProperty\"\nenum.country.cd=刚果民主共和国\n# suppress inspection \"UnusedProperty\"\nenum.country.ck=库克群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.cr=哥斯达黎加\n# suppress inspection \"UnusedProperty\"\nenum.country.ci=科特迪瓦\n# suppress inspection \"UnusedProperty\"\nenum.country.hr=克罗地亚\n# suppress inspection \"UnusedProperty\"\nenum.country.cu=古巴\n# suppress inspection \"UnusedProperty\"\nenum.country.cy=塞浦路斯\n# suppress inspection \"UnusedProperty\"\nenum.country.cz=捷克共和国\n# suppress inspection \"UnusedProperty\"\nenum.country.dk=丹麦\n# suppress inspection \"UnusedProperty\"\nenum.country.dj=吉布提\n# suppress inspection \"UnusedProperty\"\nenum.country.dm=多米尼克\n# suppress inspection \"UnusedProperty\"\nenum.country.do=多米尼加共和国\n# suppress inspection \"UnusedProperty\"\nenum.country.ec=厄瓜多尔\n# suppress inspection \"UnusedProperty\"\nenum.country.eg=埃及\n# suppress inspection \"UnusedProperty\"\nenum.country.sv=萨尔瓦多\n# suppress inspection \"UnusedProperty\"\nenum.country.gq=赤道几内亚\n# suppress inspection \"UnusedProperty\"\nenum.country.er=厄立特里亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ee=爱沙尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.et=埃塞俄比亚\n# suppress inspection \"UnusedProperty\"\nenum.country.fk=福克兰群岛（马尔维纳斯群岛）\n# suppress inspection \"UnusedProperty\"\nenum.country.fo=法罗群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.fj=斐济\n# suppress inspection \"UnusedProperty\"\nenum.country.fi=芬兰\n# suppress inspection \"UnusedProperty\"\nenum.country.fr=法国\n# suppress inspection \"UnusedProperty\"\nenum.country.gf=法属圭亚那\n# suppress inspection \"UnusedProperty\"\nenum.country.pf=法属波利尼西亚\n# suppress inspection \"UnusedProperty\"\nenum.country.tf=法属南方领地\n# suppress inspection \"UnusedProperty\"\nenum.country.ga=加蓬\n# suppress inspection \"UnusedProperty\"\nenum.country.gm=冈比亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ge=格鲁吉亚\n# suppress inspection \"UnusedProperty\"\nenum.country.de=德国\n# suppress inspection \"UnusedProperty\"\nenum.country.gh=加纳\n# suppress inspection \"UnusedProperty\"\nenum.country.gi=直布罗陀\n# suppress inspection \"UnusedProperty\"\nenum.country.gr=希腊\n# suppress inspection \"UnusedProperty\"\nenum.country.gl=格陵兰\n# suppress inspection \"UnusedProperty\"\nenum.country.gd=格林纳达\n# suppress inspection \"UnusedProperty\"\nenum.country.gp=瓜德罗普\n# suppress inspection \"UnusedProperty\"\nenum.country.gu=关岛\n# suppress inspection \"UnusedProperty\"\nenum.country.gt=危地马拉\n# suppress inspection \"UnusedProperty\"\nenum.country.gg=根西岛\n# suppress inspection \"UnusedProperty\"\nenum.country.gn=几内亚\n# suppress inspection \"UnusedProperty\"\nenum.country.gw=几内亚比绍\n# suppress inspection \"UnusedProperty\"\nenum.country.gy=圭亚那\n# suppress inspection \"UnusedProperty\"\nenum.country.ht=海地\n# suppress inspection \"UnusedProperty\"\nenum.country.hm=赫德岛和麦克唐纳群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.va=梵蒂冈（梵蒂冈城国）\n# suppress inspection \"UnusedProperty\"\nenum.country.hn=洪都拉斯\n# suppress inspection \"UnusedProperty\"\nenum.country.hk=中国香港特别行政区\n# suppress inspection \"UnusedProperty\"\nenum.country.hu=匈牙利\n# suppress inspection \"UnusedProperty\"\nenum.country.is=冰岛\n# suppress inspection \"UnusedProperty\"\nenum.country.in=印度\n# suppress inspection \"UnusedProperty\"\nenum.country.id=印度尼西亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ir=伊朗\n# suppress inspection \"UnusedProperty\"\nenum.country.iq=伊拉克\n# suppress inspection \"UnusedProperty\"\nenum.country.ie=爱尔兰\n# suppress inspection \"UnusedProperty\"\nenum.country.im=马恩岛\n# suppress inspection \"UnusedProperty\"\nenum.country.il=以色列\n# suppress inspection \"UnusedProperty\"\nenum.country.it=意大利\n# suppress inspection \"UnusedProperty\"\nenum.country.jm=牙买加\n# suppress inspection \"UnusedProperty\"\nenum.country.jp=日本\n# suppress inspection \"UnusedProperty\"\nenum.country.je=泽西岛\n# suppress inspection \"UnusedProperty\"\nenum.country.jo=约旦\n# suppress inspection \"UnusedProperty\"\nenum.country.kz=哈萨克斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.ke=肯尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ki=基里巴斯\n# suppress inspection \"UnusedProperty\"\nenum.country.kp=朝鲜\n# suppress inspection \"UnusedProperty\"\nenum.country.kr=韩国\n# suppress inspection \"UnusedProperty\"\nenum.country.kw=科威特\n# suppress inspection \"UnusedProperty\"\nenum.country.kg=吉尔吉斯斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.la=老挝人民民主共和国\n# suppress inspection \"UnusedProperty\"\nenum.country.lv=拉脱维亚\n# suppress inspection \"UnusedProperty\"\nenum.country.lb=黎巴嫩\n# suppress inspection \"UnusedProperty\"\nenum.country.ls=莱索托\n# suppress inspection \"UnusedProperty\"\nenum.country.lr=利比里亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ly=利比亚\n# suppress inspection \"UnusedProperty\"\nenum.country.li=列支敦士登\n# suppress inspection \"UnusedProperty\"\nenum.country.lt=立陶宛\n# suppress inspection \"UnusedProperty\"\nenum.country.lu=卢森堡\n# suppress inspection \"UnusedProperty\"\nenum.country.mo=中国澳门特别行政区\n# suppress inspection \"UnusedProperty\"\nenum.country.mk=马其顿\n# suppress inspection \"UnusedProperty\"\nenum.country.mg=马达加斯加\n# suppress inspection \"UnusedProperty\"\nenum.country.mw=马拉维\n# suppress inspection \"UnusedProperty\"\nenum.country.my=马来西亚\n# suppress inspection \"UnusedProperty\"\nenum.country.mv=马尔代夫\n# suppress inspection \"UnusedProperty\"\nenum.country.ml=马里\n# suppress inspection \"UnusedProperty\"\nenum.country.mt=马耳他\n# suppress inspection \"UnusedProperty\"\nenum.country.mh=马绍尔群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.mq=马提尼克\n# suppress inspection \"UnusedProperty\"\nenum.country.mr=毛里塔尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.mu=毛里求斯\n# suppress inspection \"UnusedProperty\"\nenum.country.yt=马约特\n# suppress inspection \"UnusedProperty\"\nenum.country.mx=墨西哥\n# suppress inspection \"UnusedProperty\"\nenum.country.fm=密克罗尼西亚\n# suppress inspection \"UnusedProperty\"\nenum.country.md=摩尔多瓦\n# suppress inspection \"UnusedProperty\"\nenum.country.mc=摩纳哥\n# suppress inspection \"UnusedProperty\"\nenum.country.mn=蒙古\n# suppress inspection \"UnusedProperty\"\nenum.country.me=黑山\n# suppress inspection \"UnusedProperty\"\nenum.country.ms=蒙特塞拉特\n# suppress inspection \"UnusedProperty\"\nenum.country.ma=摩洛哥\n# suppress inspection \"UnusedProperty\"\nenum.country.mz=莫桑比克\n# suppress inspection \"UnusedProperty\"\nenum.country.mm=缅甸\n# suppress inspection \"UnusedProperty\"\nenum.country.na=纳米比亚\n# suppress inspection \"UnusedProperty\"\nenum.country.nr=瑙鲁\n# suppress inspection \"UnusedProperty\"\nenum.country.np=尼泊尔\n# suppress inspection \"UnusedProperty\"\nenum.country.nl=荷兰\n# suppress inspection \"UnusedProperty\"\nenum.country.an=荷属安的列斯\n# suppress inspection \"UnusedProperty\"\nenum.country.nc=新喀里多尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.nz=新西兰\n# suppress inspection \"UnusedProperty\"\nenum.country.ni=尼加拉瓜\n# suppress inspection \"UnusedProperty\"\nenum.country.ne=尼日尔\n# suppress inspection \"UnusedProperty\"\nenum.country.ng=尼日利亚\n# suppress inspection \"UnusedProperty\"\nenum.country.nu=纽埃\n# suppress inspection \"UnusedProperty\"\nenum.country.nf=诺福克岛\n# suppress inspection \"UnusedProperty\"\nenum.country.mp=北马里亚纳群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.no=挪威\n# suppress inspection \"UnusedProperty\"\nenum.country.om=阿曼\n# suppress inspection \"UnusedProperty\"\nenum.country.pk=巴基斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.pw=帕劳\n# suppress inspection \"UnusedProperty\"\nenum.country.ps=巴勒斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.pa=巴拿马\n# suppress inspection \"UnusedProperty\"\nenum.country.pg=巴布亚新几内亚\n# suppress inspection \"UnusedProperty\"\nenum.country.py=巴拉圭\n# suppress inspection \"UnusedProperty\"\nenum.country.pe=秘鲁\n# suppress inspection \"UnusedProperty\"\nenum.country.ph=菲律宾\n# suppress inspection \"UnusedProperty\"\nenum.country.pn=皮特凯恩群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.pl=波兰\n# suppress inspection \"UnusedProperty\"\nenum.country.pt=葡萄牙\n# suppress inspection \"UnusedProperty\"\nenum.country.pr=波多黎各\n# suppress inspection \"UnusedProperty\"\nenum.country.qa=卡塔尔\n# suppress inspection \"UnusedProperty\"\nenum.country.re=留尼汪\n# suppress inspection \"UnusedProperty\"\nenum.country.ro=罗马尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.ru=俄罗斯\n# suppress inspection \"UnusedProperty\"\nenum.country.rw=卢旺达\n# suppress inspection \"UnusedProperty\"\nenum.country.sh=圣赫勒拿、阿森松和特里斯坦-达库尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.kn=圣基茨和尼维斯\n# suppress inspection \"UnusedProperty\"\nenum.country.lc=圣卢西亚\n# suppress inspection \"UnusedProperty\"\nenum.country.pm=圣皮埃尔和密克隆\n# suppress inspection \"UnusedProperty\"\nenum.country.vc=圣文森特和格林纳丁斯\n# suppress inspection \"UnusedProperty\"\nenum.country.ws=萨摩亚\n# suppress inspection \"UnusedProperty\"\nenum.country.sm=圣马力诺\n# suppress inspection \"UnusedProperty\"\nenum.country.st=圣多美和普林西比\n# suppress inspection \"UnusedProperty\"\nenum.country.sa=沙特阿拉伯\n# suppress inspection \"UnusedProperty\"\nenum.country.sn=塞内加尔\n# suppress inspection \"UnusedProperty\"\nenum.country.rs=塞尔维亚\n# suppress inspection \"UnusedProperty\"\nenum.country.sc=塞舌尔\n# suppress inspection \"UnusedProperty\"\nenum.country.sl=塞拉利昂\n# suppress inspection \"UnusedProperty\"\nenum.country.sg=新加坡\n# suppress inspection \"UnusedProperty\"\nenum.country.sk=斯洛伐克\n# suppress inspection \"UnusedProperty\"\nenum.country.si=斯洛文尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.sb=所罗门群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.so=索马里\n# suppress inspection \"UnusedProperty\"\nenum.country.za=南非\n# suppress inspection \"UnusedProperty\"\nenum.country.gs=南乔治亚和南桑威奇群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.ss=南苏丹\n# suppress inspection \"UnusedProperty\"\nenum.country.es=西班牙\n# suppress inspection \"UnusedProperty\"\nenum.country.lk=斯里兰卡\n# suppress inspection \"UnusedProperty\"\nenum.country.sd=苏丹\n# suppress inspection \"UnusedProperty\"\nenum.country.sr=苏里南\n# suppress inspection \"UnusedProperty\"\nenum.country.sj=斯瓦尔巴和扬马延\n# suppress inspection \"UnusedProperty\"\nenum.country.sz=斯威士兰\n# suppress inspection \"UnusedProperty\"\nenum.country.se=瑞典\n# suppress inspection \"UnusedProperty\"\nenum.country.ch=瑞士\n# suppress inspection \"UnusedProperty\"\nenum.country.sy=叙利亚\n# suppress inspection \"UnusedProperty\"\nenum.country.tw=中国台湾省\n# suppress inspection \"UnusedProperty\"\nenum.country.tj=塔吉克斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.tz=坦桑尼亚\n# suppress inspection \"UnusedProperty\"\nenum.country.th=泰国\n# suppress inspection \"UnusedProperty\"\nenum.country.tl=东帝汶\n# suppress inspection \"UnusedProperty\"\nenum.country.tg=多哥\n# suppress inspection \"UnusedProperty\"\nenum.country.tk=托克劳\n# suppress inspection \"UnusedProperty\"\nenum.country.to=汤加\n# suppress inspection \"UnusedProperty\"\nenum.country.tt=特立尼达和多巴哥\n# suppress inspection \"UnusedProperty\"\nenum.country.tn=突尼斯\n# suppress inspection \"UnusedProperty\"\nenum.country.tr=土耳其\n# suppress inspection \"UnusedProperty\"\nenum.country.tm=土库曼斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.tc=特克斯和凯科斯群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.tv=图瓦卢\n# suppress inspection \"UnusedProperty\"\nenum.country.ug=乌干达\n# suppress inspection \"UnusedProperty\"\nenum.country.ua=乌克兰\n# suppress inspection \"UnusedProperty\"\nenum.country.ae=阿联酋\n# suppress inspection \"UnusedProperty\"\nenum.country.gb=英国\n# suppress inspection \"UnusedProperty\"\nenum.country.us=美国\n# suppress inspection \"UnusedProperty\"\nenum.country.um=美国本土外小岛屿\n# suppress inspection \"UnusedProperty\"\nenum.country.uy=乌拉圭\n# suppress inspection \"UnusedProperty\"\nenum.country.uz=乌兹别克斯坦\n# suppress inspection \"UnusedProperty\"\nenum.country.vu=瓦努阿图\n# suppress inspection \"UnusedProperty\"\nenum.country.ve=委内瑞拉\n# suppress inspection \"UnusedProperty\"\nenum.country.vn=越南\n# suppress inspection \"UnusedProperty\"\nenum.country.vg=英属维尔京群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.vi=美属维尔京群岛\n# suppress inspection \"UnusedProperty\"\nenum.country.wf=瓦利斯和富图纳\n# suppress inspection \"UnusedProperty\"\nenum.country.eh=西撒哈拉\n# suppress inspection \"UnusedProperty\"\nenum.country.ye=也门\n# suppress inspection \"UnusedProperty\"\nenum.country.zm=赞比亚\n# suppress inspection \"UnusedProperty\"\nenum.country.zw=津巴布韦\n# suppress inspection \"UnusedProperty\"\nenum.country.tor=Tor\n# suppress inspection \"UnusedProperty\"\nenum.country.i2p=I2P\n# suppress inspection \"UnusedProperty\"\nenum.country.lan=局域网\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/AppNameTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common;\n\nimport io.xeres.testutils.TestUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass AppNameTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(AppName.class);\n\t}\n\n\t@Test\n\tvoid Name_NotBlank()\n\t{\n\t\tassertTrue(StringUtils.isNotBlank(AppName.NAME));\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/CommonCodingRulesTest.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common;\n\nimport com.tngtech.archunit.core.domain.JavaClass;\nimport com.tngtech.archunit.core.domain.JavaModifier;\nimport com.tngtech.archunit.core.importer.ImportOption;\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\nimport com.tngtech.archunit.lang.ArchCondition;\nimport com.tngtech.archunit.lang.ArchRule;\nimport com.tngtech.archunit.lang.ConditionEvents;\nimport com.tngtech.archunit.lang.SimpleConditionEvent;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.MsgId;\nimport org.slf4j.Logger;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;\nimport static com.tngtech.archunit.library.GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;\n\n@SuppressWarnings(\"unused\")\n@AnalyzeClasses(packagesOf = AppName.class, importOptions = ImportOption.DoNotIncludeTests.class)\nclass CommonCodingRulesTest\n{\n\t@ArchTest\n\tprivate final ArchRule noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;\n\n\t/**\n\t * The serializer uses the 'LENGTH' field of identifiers to be able to deserialize them.\n\t * Make sure they all implement one.\n\t */\n\t@ArchTest\n\tprivate final ArchRule identifierPublicLengthField = classes()\n\t\t\t.that().areAssignableTo(Identifier.class)\n\t\t\t.and().doNotBelongToAnyOf(Identifier.class)\n\t\t\t.should(new ArchCondition<>(\"have a public field called LENGTH\")\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tpublic void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t{\n\t\t\t\t\tboolean satisfied = javaClass.getField(\"LENGTH\").getModifiers().contains(JavaModifier.PUBLIC);\n\t\t\t\t\tString message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t+ \" a public field called LENGTH\";\n\t\t\t\t\tevents.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t}\n\t\t\t});\n\n\t@ArchTest\n\tprivate final ArchRule loggersShouldBeFinalAndStatic =\n\t\t\tfields().that().haveRawType(Logger.class)\n\t\t\t\t\t.should().bePrivate().orShould().beProtected()\n\t\t\t\t\t.andShould().beStatic().orShould().beProtected()\n\t\t\t\t\t.andShould().beFinal()\n\t\t\t\t\t.because(\"we agreed on this convention\");\n\n\t@ArchTest\n\tprivate final ArchRule utilityClass = classes()\n\t\t\t.that().haveSimpleNameEndingWith(\"Utils\")\n\t\t\t.should(new ArchCondition<>(\"have a private constructor without parameters\")\n\t\t\t        {\n\t\t\t\t        @Override\n\t\t\t\t        public void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t        {\n\t\t\t\t\t        boolean satisfied = javaClass.getConstructors().stream()\n\t\t\t\t\t\t\t        .anyMatch(constructor ->\n\t\t\t\t\t\t\t\t\t        constructor.getModifiers().contains(JavaModifier.PRIVATE)\n\t\t\t\t\t\t\t\t\t\t\t        && constructor.getParameters().isEmpty()\n\t\t\t\t\t\t\t        );\n\t\t\t\t\t        String message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t        + \" a private constructor without parameters\";\n\t\t\t\t\t        events.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t        }\n\t\t\t        }\n\t\t\t)\n\t\t\t.andShould().haveModifier(JavaModifier.FINAL);\n\n\t@ArchTest\n\tprivate final ArchRule gxsIdFieldNaming =\n\t\t\tfields().that().haveRawType(GxsId.class)\n\t\t\t\t\t.should().haveNameEndingWith(\"GxsId\")\n\t\t\t\t\t.orShould().haveName(\"gxsId\")\n\t\t\t\t\t.because(\"The name could be confused with database IDs\");\n\n\t@ArchTest\n\tprivate final ArchRule msgIdFieldNaming =\n\t\t\tfields().that().haveRawType(MsgId.class)\n\t\t\t\t\t.should().haveNameEndingWith(\"MsgId\")\n\t\t\t\t\t.orShould().haveName(\"msgId\")\n\t\t\t\t\t.because(\"The name could be confused with database IDs\");\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/file/FileTypeTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.file;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static io.xeres.common.file.FileType.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass FileTypeTest\n{\n\t@Test\n\tvoid GetTypeByExtension_MissingExtension_Success()\n\t{\n\t\tassertEquals(ANY, getTypeByExtension(\"foobar.\"));\n\t}\n\n\t@Test\n\tvoid GetTypeByExtension_NoExtension_Success()\n\t{\n\t\tassertEquals(ANY, getTypeByExtension(\"foobar\"));\n\t}\n\n\t@Test\n\tvoid GetTypeByExtension_Variants_Success()\n\t{\n\t\tassertEquals(AUDIO, getTypeByExtension(\"foobar.aac\"));\n\t\tassertEquals(AUDIO, getTypeByExtension(\"foobar.mp3\"));\n\t\tassertEquals(ARCHIVE, getTypeByExtension(\"foobar.tar\"));\n\t\tassertEquals(DOCUMENT, getTypeByExtension(\"foobar.doc\"));\n\t\tassertEquals(PICTURE, getTypeByExtension(\"foobar.jpg\"));\n\t\tassertEquals(PROGRAM, getTypeByExtension(\"foobar.exe\"));\n\t\tassertEquals(VIDEO, getTypeByExtension(\"foobar.avi\"));\n\t\tassertEquals(SUBTITLES, getTypeByExtension(\"foobar.srt\"));\n\t\tassertEquals(COLLECTION, getTypeByExtension(\"foobar.rscollection\"));\n\t}\n\n\t@Test\n\tvoid GetTypeByExtension_NotFound_Success()\n\t{\n\t\tassertEquals(ANY, getTypeByExtension(\"foobar.dtc\"));\n\t}\n\n\t/**\n\t * Makes sure that no extension is in more than one group.\n\t */\n\t@Test\n\tvoid GetExtensions_NoCrossMatches()\n\t{\n\t\tSet<String> all = new HashSet<>();\n\t\tall.addAll(AUDIO.getExtensions());\n\t\tall.addAll(ARCHIVE.getExtensions());\n\t\tall.addAll(DOCUMENT.getExtensions());\n\t\tall.addAll(PICTURE.getExtensions());\n\t\tall.addAll(PROGRAM.getExtensions());\n\t\tall.addAll(VIDEO.getExtensions());\n\t\tall.addAll(SUBTITLES.getExtensions());\n\t\tall.addAll(COLLECTION.getExtensions());\n\n\t\tassertEquals(all.size(),\n\t\t\t\tAUDIO.getExtensions().size() +\n\t\t\t\t\t\tARCHIVE.getExtensions().size() +\n\t\t\t\t\t\tDOCUMENT.getExtensions().size() +\n\t\t\t\t\t\tPICTURE.getExtensions().size() +\n\t\t\t\t\t\tPROGRAM.getExtensions().size() +\n\t\t\t\t\t\tVIDEO.getExtensions().size() +\n\t\t\t\t\t\tSUBTITLES.getExtensions().size() +\n\t\t\t\t\t\tCOLLECTION.getExtensions().size(),\n\t\t\t\t\"There's a file extension which is in more than one group\");\n\t}\n}"
  },
  {
    "path": "common/src/test/java/io/xeres/common/id/IdTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.id;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport java.math.BigInteger;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass IdTest\n{\n\t@Test\n\tvoid Instance_Throws() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(Id.class);\n\t}\n\n\t@Test\n\tvoid ToString_FromBytes_Success()\n\t{\n\t\tvar value = \"13352839ab34093f\";\n\t\tvar id = new BigInteger(value, 16);\n\n\t\tvar result = Id.toString(id.toByteArray());\n\n\t\tassertEquals(value, result);\n\t}\n\n\t@Test\n\tvoid ToString_FromBytes_Null_Success()\n\t{\n\t\tvar result = Id.toString((byte[]) null);\n\n\t\tassertEquals(\"\", result);\n\t}\n\n\t@Test\n\tvoid ToString_FromBytes_Empty_Success()\n\t{\n\t\tvar result = Id.toString(new byte[0]);\n\n\t\tassertEquals(\"\", result);\n\t}\n\n\t@Test\n\tvoid ToBytes_FromString_Success()\n\t{\n\t\tvar id = \"e40f238ecb395023\";\n\n\t\tvar result = Id.toBytes(id);\n\n\t\tassertArrayEquals(new byte[]{(byte) 0xe4, 0xf, 0x23, (byte) 0x8e, (byte) 0xcb, 0x39, 0x50, 0x23}, result);\n\t}\n\n\t@Test\n\tvoid ToBytes_FromString_Null_Success()\n\t{\n\t\tvar result = Id.toBytes(null);\n\n\t\tassertArrayEquals(new byte[0], result);\n\t}\n\n\t@Test\n\tvoid ToBytes_FromString_Empty_Success()\n\t{\n\t\tvar result = Id.toBytes(\"\");\n\n\t\tassertArrayEquals(new byte[0], result);\n\t}\n\n\t@Test\n\tvoid ToString_FromLong_Success()\n\t{\n\t\tvar id = 0x843303842344ab38L;\n\n\t\tvar result = Id.toString(id);\n\n\t\tassertEquals(\"843303842344AB38\", result);\n\t}\n\n\t@Test\n\tvoid ToString_FromLong_Negative_LowerCase_Success()\n\t{\n\t\tvar id = 0xf43303842344ab38L;\n\n\t\tvar result = Id.toStringLowerCase(id);\n\n\t\tassertEquals(\"f43303842344ab38\", result);\n\t}\n\n\t@Test\n\tvoid ToString_FromLong_Negative_Success()\n\t{\n\t\tvar id = 0xf43303842344ab38L;\n\n\t\tvar result = Id.toString(id);\n\n\t\tassertEquals(\"F43303842344AB38\", result);\n\t}\n\n\t@Test\n\tvoid ToString_FromLong_ZeroPrefix_Success()\n\t{\n\t\tvar id = 0x0344ab38L;\n\n\t\tvar result = Id.toString(id);\n\n\t\tassertEquals(\"000000000344AB38\", result);\n\t}\n\n\t@Test\n\tvoid ToString_FromIdentifier_Success()\n\t{\n\t\tvar gxsId = new GxsId(new byte[]{0x32, 0x5e, 0x38, 0x1, (byte) 0x98, (byte) 0x8a, 0x34, 0x73, 0x47, (byte) 0xef, 0x3e, 0x5a, (byte) 0xe2, 0x4a, 0x63, (byte) 0xba});\n\n\t\tvar result = Id.toString(gxsId);\n\n\t\tassertEquals(\"325e3801988a347347ef3e5ae24a63ba\", result);\n\t}\n\n\t@Test\n\tvoid AsciiToBytes_Success()\n\t{\n\t\tbyte[] id = {0x30, 0x30, 0x35, 0x36, 0x33, 0x65, 0x38, 0x36, 0x61, 0x31, 0x64, 0x62, 0x36, 0x61, 0x61, 0x30, 0x32, 0x64, 0x36, 0x62, 0x36, 0x65, 0x38, 0x66, 0x37, 0x64, 0x61, 0x32, 0x62, 0x36, 0x39, 0x35};\n\n\t\tvar result = Id.asciiToBytes(id);\n\n\t\tassertArrayEquals(new byte[]{0x0, 0x56, 0x3e, (byte) 0x86, (byte) 0xa1, (byte) 0xdb, 0x6a, (byte) 0xa0, 0x2d, 0x6b, 0x6e, (byte) 0x8f, 0x7d, (byte) 0xa2, (byte) 0xb6, (byte) 0x95}, result);\n\t}\n\n\t@Test\n\tvoid IdentifierToAscii_Success()\n\t{\n\t\tvar gxsId = new GxsId(new byte[]{0x32, 0x5e, 0x38, 0x1, (byte) 0x98, (byte) 0x8a, 0x34, 0x73, 0x47, (byte) 0xef, 0x3e, 0x5a, (byte) 0xe2, 0x4a, 0x63, (byte) 0xba});\n\n\t\tvar result = Id.toAsciiBytes(gxsId);\n\n\t\tassertArrayEquals(new byte[]{0x33, 0x32, 0x35, 0x65, 0x33, 0x38, 0x30, 0x31, 0x39, 0x38, 0x38, 0x61, 0x33, 0x34, 0x37, 0x33, 0x34, 0x37, 0x65, 0x66, 0x33, 0x65, 0x35, 0x61, 0x65, 0x32, 0x34, 0x61, 0x36, 0x33, 0x62, 0x61}, result);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/identity/TypeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.identity;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.common.identity.Type.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass TypeTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, OTHER.ordinal());\n\t\tassertEquals(1, OWN.ordinal());\n\t\tassertEquals(2, FRIEND.ordinal());\n\t\tassertEquals(3, BANNED.ordinal());\n\n\t\tassertEquals(4, values().length);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/pgp/TrustTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.pgp;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.common.pgp.Trust.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass TrustTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, UNKNOWN.ordinal());\n\t\tassertEquals(1, NEVER.ordinal());\n\t\tassertEquals(2, MARGINAL.ordinal());\n\t\tassertEquals(3, FULL.ordinal());\n\t\tassertEquals(4, ULTIMATE.ordinal());\n\n\t\tassertEquals(5, values().length);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/protocol/HostPortTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass HostPortTest\n{\n\t@Test\n\tvoid Parse_Success()\n\t{\n\t\tvar host = \"hey.foobar.com\";\n\t\tvar port = 1234;\n\n\t\tvar hostPort = HostPort.parse(host + \":\" + port);\n\n\t\tassertEquals(host, hostPort.host());\n\t\tassertEquals(port, hostPort.port());\n\t}\n\n\t@Test\n\tvoid Parse_WrongFormat_ThrowsException()\n\t{\n\t\tvar host = \"hey.foobar.com\";\n\n\t\tassertThrows(IllegalArgumentException.class, () -> HostPort.parse(host), \"Input is not in \\\"host:port\\\" format: hey.foobar.com\");\n\t}\n\n\t@Test\n\tvoid Parse_MissingHost_ThrowsException()\n\t{\n\t\tvar host = \"\";\n\n\t\tassertThrows(IllegalArgumentException.class, () -> HostPort.parse(host), \"Host is missing\");\n\t}\n\n\t@Test\n\tvoid Parse_MissingPort_ThrowsException()\n\t{\n\t\tvar host = \"hey.foobar.com\";\n\n\t\tassertThrows(IllegalArgumentException.class, () -> HostPort.parse(host + \":\"), \"Port is not a number: \");\n\t}\n\n\t@Test\n\tvoid Parse_PortNotANumber_ThrowsException()\n\t{\n\t\tvar host = \"hey.foobar.com\";\n\t\tvar port = \"plop\";\n\n\t\tassertThrows(IllegalArgumentException.class, () -> HostPort.parse(host + \":\" + port), \"Port is not a number: \" + port);\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(ints = {-1, 65536})\n\tvoid Parse_PortOutOfRange_ThrowsException(int port)\n\t{\n\t\tvar host = \"hey.foobar.com\";\n\n\t\tassertThrows(IllegalArgumentException.class, () -> HostPort.parse(host + \":\" + port), \"Port is out of range: \" + port);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/protocol/NetModeTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.common.protocol.NetMode.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass NetModeTest\n{\n\t@Test\n\tvoid Enum_Order_Fixed()\n\t{\n\t\tassertEquals(0, UNKNOWN.ordinal());\n\t\tassertEquals(1, UDP.ordinal());\n\t\tassertEquals(2, UPNP.ordinal());\n\t\tassertEquals(3, EXT.ordinal());\n\t\tassertEquals(4, HIDDEN.ordinal());\n\t\tassertEquals(5, UNREACHABLE.ordinal());\n\t\tassertEquals(6, values().length);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/protocol/dns/DNSTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.dns;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;\n\nimport java.io.IOException;\nimport java.net.InetAddress;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass DNSTest\n{\n\t/**\n\t * This test verifies that myip.opendns.com works for finding one's own IP when UPNP is not working.\n\t * It also tests that akamai works, in case opendns is removed, and we need to fall back to something else.\n\t * It only runs on my machine because of the chicken & egg problem on knowing one's own IP.\n\t *\n\t */\n\t@Test\n\t@EnabledIfEnvironmentVariable(named = \"COMPUTERNAME\", matches = \"B650\")\n\tvoid Resolve_Success() throws IOException\n\t{\n\t\tvar ip1 = DNS.resolve(\"myip.opendns.com\", \"208.67.222.222\"); // resolver1.opendns.com\n\t\tvar ip2 = DNS.resolve(\"myip.opendns.com\", \"208.67.220.220\"); // resolver2.opendns.com\n\t\tvar ip3 = DNS.resolve(\"myip.opendns.com\", \"208.67.222.220\"); // resolver3.opendns.com\n\t\tvar ip4 = DNS.resolve(\"myip.opendns.com\", \"208.67.220.222\"); // resolver4.opendns.com\n\t\tvar ip5 = DNS.resolve(\"whoami.akamai.net\", \"193.108.88.1\"); // ns1-1.akamaitech.net\n\t\tvar realIp = InetAddress.getByName(\"core.zapek.com\");\n\t\tassertEquals(realIp, ip1);\n\t\tassertEquals(realIp, ip2);\n\t\tassertEquals(realIp, ip3);\n\t\tassertEquals(realIp, ip4);\n\t\tassertEquals(realIp, ip5);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/protocol/ip/IPTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.protocol.ip;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass IPTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(IP.class);\n\t}\n\n\t@Test\n\tvoid GetFreeLocalPort_Success()\n\t{\n\t\tvar port = IP.getFreeLocalPort();\n\n\t\tassertTrue(port >= 1025 && port <= 32766);\n\t}\n\n\t@Test\n\tvoid GetLocalIPAddress_Success()\n\t{\n\t\tvar ip = IP.getLocalIpAddress();\n\n\t\tassertNotNull(ip);\n\t}\n\n\t@Test\n\tvoid IsLanIP_Various_Success()\n\t{\n\t\tassertTrue(IP.isLanIp(\"10.0.0.0\"));\n\t\tassertTrue(IP.isLanIp(\"10.255.255.255\"));\n\n\t\tassertTrue(IP.isLanIp(\"172.16.0.0\"));\n\t\tassertTrue(IP.isLanIp(\"172.31.255.255\"));\n\n\t\tassertTrue(IP.isLanIp(\"192.168.0.0\"));\n\t\tassertTrue(IP.isLanIp(\"192.168.255.255\"));\n\n\t\tassertTrue(IP.isLanIp(\"192.168.1.5\"));\n\t\tassertTrue(IP.isLanIp(\"172.16.0.5\"));\n\t\tassertTrue(IP.isLanIp(\"10.0.0.5\"));\n\t}\n\n\t@Test\n\tvoid IsLanIP_WAN_Failure()\n\t{\n\t\tassertFalse(IP.isLanIp(\"85.1.2.78\"));\n\t}\n\n\t@Test\n\tvoid IsLanIP_Empty_Failure()\n\t{\n\t\tassertFalse(IP.isLanIp(\"\"));\n\t}\n\n\t@Test\n\tvoid IsLanIP_Null_Failure()\n\t{\n\t\tassertFalse(IP.isLanIp(null));\n\t}\n\n\t@Test\n\tvoid IsPublicIP_WAN_Success()\n\t{\n\t\tassertTrue(IP.isPublicIp(\"85.1.2.78\"));\n\t}\n\n\t@Test\n\tvoid IsPublicIP_LAN_Failure()\n\t{\n\t\tassertFalse(IP.isPublicIp(\"192.168.1.5\"));\n\t}\n\n\t@Test\n\tvoid IsPublicIP_Empty_Failure()\n\t{\n\t\tassertFalse(IP.isPublicIp(\"\"));\n\t}\n\n\t@Test\n\tvoid IsPublicIP_Null_Failure()\n\t{\n\t\tassertFalse(IP.isPublicIp(null));\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/rest/notification/StatusNotificationTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.rest.notification;\n\nimport io.xeres.common.rest.notification.status.DhtInfo;\nimport io.xeres.common.rest.notification.status.DhtStatus;\nimport io.xeres.common.rest.notification.status.NatStatus;\nimport io.xeres.common.rest.notification.status.StatusNotification;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\n\nclass StatusNotificationTest\n{\n\tprivate final StatusNotification response1 = new StatusNotification(0, 1, NatStatus.UPNP, DhtInfo.fromStatus(DhtStatus.OFF));\n\tprivate final StatusNotification response2 = new StatusNotification(0, 1, NatStatus.UPNP, DhtInfo.fromStatus(DhtStatus.OFF));\n\tprivate final StatusNotification response3 = new StatusNotification(0, 1, NatStatus.FIREWALLED, DhtInfo.fromStatus(DhtStatus.OFF));\n\n\t@Test\n\tvoid Equals_Success()\n\t{\n\t\tassertEquals(response1, response2);\n\t}\n\n\t@Test\n\tvoid Equals_Variant1_Failure()\n\t{\n\t\tassertNotEquals(response1, response3);\n\t}\n\n\t@Test\n\tvoid Equals_Variant2_Failure()\n\t{\n\t\tassertNotEquals(response2, response3);\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/util/ByteUnitUtilsTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport org.junit.jupiter.api.Test;\n\nimport static io.xeres.common.util.ByteUnitUtils.fromBytes;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ByteUnitUtilsTest\n{\n\t@Test\n\tvoid FromBytes_Various_Success()\n\t{\n\t\tassertEquals(\"invalid\", fromBytes(-1));\n\t\tassertEquals(\"0 bytes\", fromBytes(0));\n\t\tassertEquals(\"512 bytes\", fromBytes(512));\n\t\tassertEquals(\"1023 bytes\", fromBytes(1023));\n\t\tassertEquals(\"1024 bytes\", fromBytes(1024));\n\t\tassertEquals(\"1152 bytes\", fromBytes(1152));\n\t\tassertEquals(\"1 MB\", fromBytes(1024 * 1024));\n\t\tassertEquals(\"1.12 MB\", fromBytes(1152 * 1024));\n\t\tassertEquals(\"1 GB\", fromBytes(1024 * 1024 * 1024));\n\t\tassertEquals(\"1 TB\", fromBytes(1024L * 1024 * 1024 * 1024));\n\t\tassertEquals(\"1 PB\", fromBytes(1024L * 1024 * 1024 * 1024 * 1024));\n\t\tassertEquals(\"1 EB\", fromBytes(1024L * 1024 * 1024 * 1024 * 1024 * 1024));\n\t}\n}\n"
  },
  {
    "path": "common/src/test/java/io/xeres/common/util/FileNameUtilsTest.java",
    "content": "package io.xeres.common.util;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass FileNameUtilsTest\n{\n\t@ParameterizedTest\n\t@CsvSource({\n\t\t\t\"foo.jpg,foo (1).jpg\",\n\t\t\t\"foo,foo (1)\",\n\t\t\t\"foo.tar.gz,foo (1).tar.gz\",\n\t\t\t\"foo.bla.tgz,foo.bla (1).tgz\",\n\t\t\t\"foo.blabla.gz,foo.blabla (1).gz\",\n\t\t\t\"foo.bar.plop.tar.gz,foo.bar.plop (1).tar.gz\",\n\t\t\t\"foo (1).jpg,foo (2).jpg\",\n\t\t\t\"foo (9).jpg,foo (10).jpg\",\n\t\t\t\"foo (bar).jpg,foo (bar) (1).jpg\",\n\t\t\t\"foo (1)(2).jpg,foo (1)(3).jpg\",\n\t\t\t\"foo ().jpg,foo () (1).jpg\"\n\t})\n\tvoid Rename_Various_Success(String input, String expected)\n\t{\n\t\tvar result = FileNameUtils.rename(input);\n\t\tassertEquals(expected, result);\n\t}\n\n\t@Test\n\tvoid Rename_Empty_ThrowsException()\n\t{\n\t\tassertThrows(IllegalArgumentException.class, () -> FileNameUtils.rename(\"\"));\n\t}\n}"
  },
  {
    "path": "common/src/test/java/io/xeres/common/util/SecureRandomUtilsTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass SecureRandomUtilsTest\n{\n\t@Test\n\tvoid NextPassword_Empty_ThrowsException()\n\t{\n\t\tchar[] password = new char[0];\n\n\t\tassertThrows(IllegalArgumentException.class, () -> SecureRandomUtils.nextPassword(password));\n\t}\n\n\t@Test\n\tvoid NextPassword_Short_Success()\n\t{\n\t\tchar[] password = new char[1];\n\t\tSecureRandomUtils.nextPassword(password);\n\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit));\n\t}\n\n\t@Test\n\tvoid NextPassword_Small_Success()\n\t{\n\t\tchar[] password = new char[2];\n\t\tSecureRandomUtils.nextPassword(password);\n\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit));\n\t}\n\n\t@Test\n\tvoid NextPassword_Minimal_Success()\n\t{\n\t\tchar[] password = new char[3];\n\t\tSecureRandomUtils.nextPassword(password);\n\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit));\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isLowerCase));\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isUpperCase));\n\t}\n\n\t@Test\n\tvoid NextPassword_Normal_Success()\n\t{\n\t\tvar password = new char[20];\n\t\tSecureRandomUtils.nextPassword(password);\n\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isDigit));\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isLowerCase));\n\t\tassertTrue(String.valueOf(password).chars().anyMatch(Character::isUpperCase));\n\t}\n}"
  },
  {
    "path": "common/src/test/java/io/xeres/common/util/image/ImageUtilsTest.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.util.image;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.springframework.http.MediaType;\n\nimport javax.imageio.ImageIO;\nimport java.awt.image.BufferedImage;\nimport java.io.IOException;\nimport java.util.Objects;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ImageUtilsTest\n{\n\tprivate static BufferedImage opaqueImage;\n\tprivate static BufferedImage transparentImage;\n\n\t@BeforeAll\n\tstatic void setup() throws IOException\n\t{\n\t\topaqueImage = ImageIO.read(Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream(\"/image/ours.png\")));\n\t\ttransparentImage = ImageIO.read(Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream(\"/image/logo_transparent.png\")));\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ImageUtils.class);\n\t}\n\n\t@Test\n\tvoid WriteImageAsPngData_Image_Success()\n\t{\n\t\tvar pngImage = ImageUtils.writeImageAsPngData(ImageUtilsTest.opaqueImage, 2048);\n\n\t\tassertTrue(pngImage.startsWith(\"data:image/png;base64,iVBOR\"));\n\t}\n\n\t@Test\n\tvoid WriteImageAsJpegData_Success()\n\t{\n\t\tvar jpegImage = ImageUtils.writeImageAsJpegData(opaqueImage, 2048);\n\n\t\tassertTrue(jpegImage.startsWith(\"data:image/jpeg;base64,/9j/\"));\n\t}\n\n\t@Test\n\tvoid WriteImageAsJpegDataWithLimit_Success()\n\t{\n\t\tvar jpegImage = ImageUtils.writeImageAsJpegData(opaqueImage, 256);\n\n\t\tassertTrue(jpegImage.startsWith(\"data:image/jpeg;base64,/9j/\"));\n\t}\n\n\t@Test\n\tvoid WriteImageAsBestPossibleWhichIsJpeg_Success()\n\t{\n\t\tvar bestImage = ImageUtils.writeImage(opaqueImage, 2048);\n\n\t\tassertTrue(bestImage.startsWith(\"data:image/jpeg;base64,/9j/\"));\n\t}\n\n\t@Test\n\tvoid WriteImageAsBestPossibleWhichIsPng_Success()\n\t{\n\t\t// This image is effectively transparent and must be written as so.\n\t\tvar bestImage = ImageUtils.writeImage(transparentImage, 2048);\n\n\t\tassertTrue(bestImage.startsWith(\"data:image/png;base64,iVBOR\"));\n\t}\n\n\t@Test\n\tvoid LimitMaximumImageSize_Success()\n\t{\n\t\tvar scaledImage = ImageUtils.limitMaximumImageSize(opaqueImage, 128);\n\n\t\tassertTrue(scaledImage.getWidth() * scaledImage.getHeight() <= 128);\n\t}\n\n\t@Test\n\tvoid DetectJpeg_Success() throws IOException\n\t{\n\t\tvar jpegArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream(\"/image/hamster.jpg\")).readAllBytes();\n\t\tassertEquals(MediaType.IMAGE_JPEG, ImageUtils.getImageMimeType(jpegArray));\n\t}\n\n\t@Test\n\tvoid DetectPng_Success() throws IOException\n\t{\n\t\tvar pngArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream(\"/image/ours.png\")).readAllBytes();\n\t\tassertEquals(MediaType.IMAGE_PNG, ImageUtils.getImageMimeType(pngArray));\n\t}\n\n\t@Test\n\tvoid DetectGif_Success() throws IOException\n\t{\n\t\tvar gifArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream(\"/image/v3_anim.gif\")).readAllBytes();\n\t\tassertEquals(MediaType.IMAGE_GIF, ImageUtils.getImageMimeType(gifArray));\n\t}\n\n\t@Test\n\tvoid DetectWebP_Success() throws IOException\n\t{\n\t\tvar webpArray = Objects.requireNonNull(ImageUtilsTest.class.getResourceAsStream(\"/image/gaudie.webp\")).readAllBytes();\n\t\tassertEquals(MediaType.parseMediaType(\"image/webp\"), ImageUtils.getImageMimeType(webpArray));\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\"image/png\", \"image/webp\", \"image/svg+xml\", \"image/gif\", \"image/x-icon\"})\n\tvoid isPossiblyTransparent_Yes(String input)\n\t{\n\t\tassertTrue(ImageUtils.isPossiblyTransparent(input));\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\"image/jpeg\", \"image/bmp\", \"image/iff\"})\n\t\t// Supported mime types by WebClient are looked up from the file extension and are there: https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/resources/org/springframework/http/mime.types\n\tvoid isPossiblyTransparent_No(String input)\n\t{\n\t\tassertFalse(ImageUtils.isPossiblyTransparent(input));\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/chat/ChatIdentityDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\n\npublic final class ChatIdentityDTOFakes\n{\n\tprivate ChatIdentityDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatIdentityDTO createChatIdentityDTO()\n\t{\n\t\treturn new ChatIdentityDTO(StringFakes.createNickname(), IdFakes.createGxsId(), 10L);\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/chat/ChatRoomContextDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\npublic final class ChatRoomContextDTOFakes\n{\n\tprivate ChatRoomContextDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatRoomContextDTO createChatRoomContextDTO()\n\t{\n\t\treturn new ChatRoomContextDTO(ChatRoomsDTOFakes.createChatRoomsDTO(), ChatIdentityDTOFakes.createChatIdentityDTO());\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/chat/ChatRoomDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport io.xeres.common.message.chat.RoomType;\n\npublic final class ChatRoomDTOFakes\n{\n\tprivate ChatRoomDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatRoomDTO createChatRoomDTO()\n\t{\n\t\treturn new ChatRoomDTO(1L, \"Foobar\", RoomType.PUBLIC, \"hello\", 5, true);\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/chat/ChatRoomsDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.chat;\n\nimport java.util.List;\n\npublic final class ChatRoomsDTOFakes\n{\n\tprivate ChatRoomsDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatRoomsDTO createChatRoomsDTO()\n\t{\n\t\treturn new ChatRoomsDTO(List.of(ChatRoomDTOFakes.createChatRoomDTO()), List.of(ChatRoomDTOFakes.createChatRoomDTO()));\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/connection/ConnectionDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.connection;\n\nimport java.time.Instant;\n\npublic final class ConnectionDTOFakes\n{\n\tprivate ConnectionDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ConnectionDTO createConnectionDTO()\n\t{\n\t\treturn new ConnectionDTO(1L, \"88.89.10.11:1234\", Instant.now(), true);\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/identity/IdentityDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.identity;\n\nimport io.xeres.common.identity.Type;\nimport io.xeres.testutils.*;\n\npublic final class IdentityDTOFakes\n{\n\tprivate IdentityDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static IdentityDTO createIdentityDTO()\n\t{\n\t\treturn new IdentityDTO(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createGxsId(), TimeFakes.createInstant(), EnumFakes.create(Type.class), BooleanFakes.create());\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/location/LocationDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.location;\n\nimport io.xeres.common.dto.connection.ConnectionDTOFakes;\nimport io.xeres.common.location.Availability;\nimport io.xeres.testutils.BooleanFakes;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\nimport io.xeres.testutils.TimeFakes;\n\nimport java.util.List;\n\npublic final class LocationDTOFakes\n{\n\tprivate LocationDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static LocationDTO create()\n\t{\n\t\treturn new LocationDTO(IdFakes.createLong(), StringFakes.createNickname(), IdFakes.createLocationIdentifier().getBytes(), StringFakes.createNickname(), List.of(ConnectionDTOFakes.createConnectionDTO()), BooleanFakes.create(), TimeFakes.createInstant(), Availability.AVAILABLE, \"Xeres 2.3.2\");\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/profile/ProfileDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.profile;\n\nimport io.xeres.common.dto.location.LocationDTOFakes;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.testutils.BooleanFakes;\nimport io.xeres.testutils.EnumFakes;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\n\nimport java.time.Instant;\nimport java.util.List;\n\npublic final class ProfileDTOFakes\n{\n\tprivate ProfileDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ProfileDTO create()\n\t{\n\t\treturn new ProfileDTO(IdFakes.createLong(), StringFakes.createNickname(), Long.toString(IdFakes.createLong()), Instant.now(), new byte[20], new byte[1], BooleanFakes.create(), EnumFakes.create(Trust.class), List.of(LocationDTOFakes.create()));\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/settings/SettingsDTOFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.settings;\n\nimport io.xeres.testutils.BooleanFakes;\nimport io.xeres.testutils.IdFakes;\nimport org.apache.commons.lang3.RandomStringUtils;\n\npublic final class SettingsDTOFakes\n{\n\tprivate SettingsDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static SettingsDTO create()\n\t{\n\t\treturn new SettingsDTO(RandomStringUtils.secure().nextAlphanumeric(30),\n\t\t\t\tIdFakes.createInt(),\n\t\t\t\tRandomStringUtils.secure().nextAlphanumeric(30),\n\t\t\t\tIdFakes.createInt(),\n\t\t\t\tBooleanFakes.create(),\n\t\t\t\tBooleanFakes.create(),\n\t\t\t\tBooleanFakes.create(),\n\t\t\t\tBooleanFakes.create(),\n\t\t\t\t\"/foo/bar\",\n\t\t\t\t\"foobar1234\",\n\t\t\t\tBooleanFakes.create(),\n\t\t\t\tBooleanFakes.create(),\n\t\t\t\tIdFakes.createInt());\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/common/dto/share/ShareDTOFakes.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.common.dto.share;\n\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.testutils.BooleanFakes;\nimport io.xeres.testutils.EnumFakes;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\n\nimport java.time.Instant;\n\npublic final class ShareDTOFakes\n{\n\tprivate ShareDTOFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ShareDTO createShareDTO()\n\t{\n\t\treturn new ShareDTO(IdFakes.createLong(), StringFakes.createNickname(), \"C:\\\\foobar\", BooleanFakes.create(), EnumFakes.create(Trust.class), Instant.now());\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/BooleanFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class BooleanFakes\n{\n\tprivate BooleanFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static boolean create()\n\t{\n\t\treturn ThreadLocalRandom.current().nextBoolean();\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/EnumFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class EnumFakes\n{\n\tprivate EnumFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static <T extends Enum<?>> T create(Class<T> enumClass)\n\t{\n\t\tvar i = ThreadLocalRandom.current().nextInt(enumClass.getEnumConstants().length);\n\t\treturn enumClass.getEnumConstants()[i];\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/IdFakes.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.MsgId;\nimport org.apache.commons.lang3.RandomUtils;\n\npublic final class IdFakes\n{\n\tprivate IdFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static GxsId createGxsId()\n\t{\n\t\treturn new GxsId(RandomUtils.insecure().randomBytes(GxsId.LENGTH));\n\t}\n\n\tpublic static GxsId createGxsId(byte[] gxsId)\n\t{\n\t\treturn new GxsId(gxsId);\n\t}\n\n\tpublic static MsgId createMsgId()\n\t{\n\t\treturn new MsgId(RandomUtils.insecure().randomBytes(MsgId.LENGTH));\n\t}\n\n\tpublic static LocationIdentifier createLocationIdentifier()\n\t{\n\t\treturn new LocationIdentifier(RandomUtils.insecure().randomBytes(LocationIdentifier.LENGTH));\n\t}\n\n\tpublic static long createLong()\n\t{\n\t\treturn RandomUtils.insecure().randomLong(1, Long.MAX_VALUE);\n\t}\n\n\tpublic static int createInt()\n\t{\n\t\treturn RandomUtils.insecure().randomInt(1, Integer.MAX_VALUE);\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/Sha1SumFakes.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport io.xeres.common.id.Sha1Sum;\nimport org.apache.commons.lang3.RandomUtils;\n\npublic final class Sha1SumFakes\n{\n\tprivate Sha1SumFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Sha1Sum createSha1Sum()\n\t{\n\t\treturn new Sha1Sum(RandomUtils.insecure().randomBytes(Sha1Sum.LENGTH));\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/StringFakes.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport org.apache.commons.lang3.RandomStringUtils;\n\nimport java.util.Locale;\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class StringFakes\n{\n\tprivate static final String[] FIRSTNAME = {\n\t\t\t\"Jean\",\n\t\t\t\"Alexander\",\n\t\t\t\"Fernando\",\n\t\t\t\"Rubens\",\n\t\t\t\"Valtteri\",\n\t\t\t\"Jenson\",\n\t\t\t\"Zhou\",\n\t\t\t\"Lewis\",\n\t\t\t\"Robert\",\n\t\t\t\"Charles\",\n\t\t\t\"Kevin\",\n\t\t\t\"Felipe\",\n\t\t\t\"Nikita\",\n\t\t\t\"Lando\",\n\t\t\t\"Esteban\",\n\t\t\t\"Sergio\",\n\t\t\t\"Nelson\",\n\t\t\t\"Alain\",\n\t\t\t\"George\",\n\t\t\t\"Carlos\",\n\t\t\t\"Michael\",\n\t\t\t\"Ayrton\",\n\t\t\t\"Lance\",\n\t\t\t\"Jarno\",\n\t\t\t\"Yuki\",\n\t\t\t\"Max\",\n\t\t\t\"Sebastian\"\n\t};\n\n\tprivate static final String[] LASTNAME = {\n\t\t\t\"Alesi\",\n\t\t\t\"Albon\",\n\t\t\t\"Alonso\",\n\t\t\t\"Barrichello\",\n\t\t\t\"Bottas\",\n\t\t\t\"Button\",\n\t\t\t\"Guanyu\",\n\t\t\t\"Hamilton\",\n\t\t\t\"Kubica\",\n\t\t\t\"Leclerc\",\n\t\t\t\"Magnussen\",\n\t\t\t\"Massa\",\n\t\t\t\"Mazepin\",\n\t\t\t\"Norris\",\n\t\t\t\"Ocon\",\n\t\t\t\"Perez\",\n\t\t\t\"Piquet\",\n\t\t\t\"Prost\",\n\t\t\t\"Russell\",\n\t\t\t\"Sainz\",\n\t\t\t\"Schumacher\",\n\t\t\t\"Senna\",\n\t\t\t\"Stroll\",\n\t\t\t\"Trulli\",\n\t\t\t\"Tsunoda\",\n\t\t\t\"Verstappen\",\n\t\t\t\"Vettel\"\n\t};\n\n\tprivate StringFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String createNickname()\n\t{\n\t\tvar s = RandomStringUtils.insecure().nextAlphabetic(5, 10);\n\t\treturn s.substring(0, 1).toUpperCase(Locale.ROOT) + s.substring(1);\n\t}\n\n\tpublic static String createFirstName()\n\t{\n\t\treturn FIRSTNAME[ThreadLocalRandom.current().nextInt(FIRSTNAME.length)];\n\t}\n\n\tpublic static String createLastName()\n\t{\n\t\treturn LASTNAME[ThreadLocalRandom.current().nextInt(LASTNAME.length)];\n\t}\n\n\tpublic static String createFullName()\n\t{\n\t\treturn createFirstName() + \" \" + createLastName();\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/TestUtils.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport java.lang.reflect.InvocationTargetException;\n\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\n\npublic final class TestUtils\n{\n\tprivate TestUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static <T> void assertUtilityClass(Class<T> javaClass) throws NoSuchMethodException\n\t{\n\t\tvar declaredConstructor = javaClass.getDeclaredConstructor();\n\t\tassertFalse(declaredConstructor.canAccess(null));\n\t\tdeclaredConstructor.setAccessible(true);\n\n\t\tassertThatThrownBy(declaredConstructor::newInstance)\n\t\t\t\t.isInstanceOf(InvocationTargetException.class)\n\t\t\t\t.hasCauseInstanceOf(UnsupportedOperationException.class);\n\t}\n}\n"
  },
  {
    "path": "common/src/testFixtures/java/io/xeres/testutils/TimeFakes.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.testutils;\n\nimport java.time.Instant;\nimport java.time.ZonedDateTime;\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic final class TimeFakes\n{\n\tprivate TimeFakes()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Instant createInstant()\n\t{\n\t\tvar end = ZonedDateTime.now();\n\t\tvar start = end.minusYears(5);\n\t\tvar random = ThreadLocalRandom.current().nextLong(start.toInstant().getEpochSecond(), end.toInstant().getEpochSecond());\n\n\t\treturn Instant.ofEpochSecond(random);\n\t}\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '2.4'\nservices:\n  xeres:\n    image: zapek/xeres:0.8.0\n    ports:\n      - \"6232:6232\"\n      - \"3335:3335\"\n    environment:\n      - SPRING_PROFILES_ACTIVE=cloud\n      - XERES_SERVER_PORT=3335\n      - XERES_DATA_DIR=/tmp\n      - \"JAVA_TOOL_OPTIONS=-Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8\"\n    mem_limit: 1G\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.5.1-bin.zip\nnetworkTimeout=10000\nretries=0\nretryBackOffMs=500\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "#\n# Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n#\n# This file is part of Xeres.\n#\n# Xeres is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation, either version 3 of the License, or\n# (at your option) any later version.\n#\n# Xeres is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\norg.gradle.parallel=true\norg.gradle.configuration-cache=true\norg.gradle.configuration-cache.parallel=true\norg.gradle.caching=true\norg.gradle.configureondemand=true\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n@rem SPDX-License-Identifier: Apache-2.0\r\n@rem\r\n\r\n@if \"%DEBUG%\"==\"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables, and ensure extensions are enabled\r\nsetlocal EnableExtensions\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\r\n@rem This is normally unused\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif %ERRORLEVEL% equ 0 goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\n\"%COMSPEC%\" /c exit 1\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\n\"%COMSPEC%\" /c exit 1\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\n\r\n\r\n@rem Execute Gradle\r\n@rem endlocal doesn't take effect until after the line is parsed and variables are expanded\r\n@rem which allows us to clear the local environment before executing the java command\r\nendlocal & \"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -jar \"%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\" %* & call :exitWithErrorLevel\r\n\r\n:exitWithErrorLevel\r\n@rem Use \"%COMSPEC%\" /c exit to allow operators to work properly in scripts\r\n\"%COMSPEC%\" /c exit %ERRORLEVEL%\r\n"
  },
  {
    "path": "qodana.yaml",
    "content": "#-------------------------------------------------------------------------------#\n#               Qodana analysis is configured by qodana.yaml file               #\n#             https://www.jetbrains.com/help/qodana/qodana-yaml.html            #\n#-------------------------------------------------------------------------------#\n\n#################################################################################\n#              WARNING: Do not store sensitive information in this file,        #\n#               as its contents will be included in the Qodana report.          #\n#################################################################################\nversion: \"1.0\"\n\n#Specify inspection profile for code analysis\nprofile:\n  base:\n    name: qodana.starter\n\n  inspections:\n    - inspection: LoggingSimilarMessage\n      options:\n        myMinTextLength: 49\n\n#Enable inspections\n#include:\n#  - name: <SomeEnabledInspectionId>\n\n#Disable inspections\n#exclude:\n#  - name: <SomeDisabledInspectionId>\n#    paths:\n#      - <path/where/not/run/inspection>\n\nprojectJDK: \"25\" #(Applied in CI/CD pipeline)\n\n#Execute shell command before Qodana execution (Applied in CI/CD pipeline)\n#bootstrap: sh ./prepare-qodana.sh\n\n#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)\n#plugins:\n#  - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)\n\n# Quality gate. Will fail the CI/CD pipeline if any condition is not met\n# severityThresholds - configures maximum thresholds for different problem severities\n# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code\n# Code Coverage is available in Ultimate and Ultimate Plus plans\n#failureConditions:\n#  severityThresholds:\n#    any: 15\n#    critical: 5\n#  testCoverageThresholds:\n#    fresh: 70\n#    total: 50\n\n#Specify Qodana linter for analysis (Applied in CI/CD pipeline)\nlinter: jetbrains/qodana-jvm-community:2026.1\n"
  },
  {
    "path": "scripts/api/user.js",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n// This is an example user script for Xeres.\n//\n// Requirements: ECMA script version 2025 in strict mode.\n//\n// The script has to be placed in:\n//   Windows: %APPDATA%\\Xeres\\Scripts\\user.js\n//   Linux: /home/<account>/.local/share/Xeres/Scripts/user.js\n//   macOS: /Users/<Account>/Library/Application Support/Xeres/Scripts/user.js\n\n// There are many events that you can register for below.\n\n// Called when receiving a chat room message\nxeresAPI.registerEventHandler(\"chatRoomMessage\", function (data)\n{\n\tconsole.log(`Received chat room message from ${data.nickname} with content: ${data.content}`);\n\n\tif (data.content === '!f1')\n\t{\n\t\txeresAPI.sendChatRoomMessage(data.roomId, `${data.nickname}: ${getF1Prediction()}`);\n\t}\n\telse if (data.content === '!bullshit')\n\t{\n\t\txeresAPI.sendChatRoomMessage(data.roomId, generateBullshit());\n\t}\n\telse if (/all your .+ are belong to .+$/i.test(data.content))\n\t{\n\t\tconst ayb = [\n\t\t\t\"What happen?\",\n\t\t\t\"Someone set up us the bomb\",\n\t\t\t\"We get signal\",\n\t\t\t\"Main screen turn on.\",\n\t\t\t\"How are you gentlemen!!\",\n\t\t\t\"You are on the way to destruction\",\n\t\t\t\"What you say?\",\n\t\t\t\"You have no chance to survive make your time\",\n\t\t\t\"Take off every 'ZIG'!!\",\n\t\t\t\"Move 'ZIG'.\",\n\t\t\t\"For great justice.\",\n\t\t\t\"It's you!!\"\n\t\t];\n\t\txeresAPI.sendChatRoomMessage(data.roomId, getRandomString(ayb));\n\t}\n\n\tconsole.log(`availability: ${xeresAPI.getAvailability()}`);\n});\n\n// Called when receiving a private chat message\nxeresAPI.registerEventHandler(\"chatPrivateMessage\", function (data)\n{\n\tconsole.log(`Received private message from ${data.location} with content: ${data.content}`);\n\n\tswitch (xeresAPI.getAvailability())\n\t{\n\t\tcase \"AWAY\":\n\t\t\txeresAPI.sendPrivateMessage(data.location, \"Sorry but I'm away. I'll reply when I'm back.\");\n\t\t\tbreak;\n\n\t\tcase \"BUSY\":\n\t\t\txeresAPI.sendPrivateMessage(data.location, \"Sorry but I'm busy right now. I'll reply when I'm available again.\");\n\t\t\tbreak;\n\t}\n});\n\n// Called when receiving a distant chat message\nxeresAPI.registerEventHandler(\"chatDistantMessage\", function (data)\n{\n\tconsole.log(`Received distant message from ${data.gxsId} with content: ${data.content}`);\n\n\tswitch (xeresAPI.getAvailability())\n\t{\n\t\tcase \"AWAY\":\n\t\t\txeresAPI.sendDistantMessage(data.gxsId, \"Sorry but I'm away. I'll reply when I'm back.\");\n\t\t\tbreak;\n\n\t\tcase \"BUSY\":\n\t\t\txeresAPI.sendDistantMessage(data.gxsId, \"Sorry but I'm busy right now. I'll reply when I'm available again.\");\n\t\t\tbreak;\n\t}\n});\n\n// Called when someone joins a room\nxeresAPI.registerEventHandler(\"chatRoomJoin\", function (data)\n{\n\tconsole.log(`User ${data.nickname} (${data.gxsId}) joined chat room ${data.roomId}`);\n\n\tif (xeresAPI.getAvailability() === \"AVAILABLE\")\n\t{\n\t\txeresAPI.sendChatRoomMessage(data.roomId, `welcome ${data.nickname}!`);\n\t}\n});\n\n// Called when getting a chat room invitation\nxeresAPI.registerEventHandler(\"chatRoomInvite\", function (data)\n{\n\tconsole.log(`Location ${data.location} invited you to room id ${data.roomId}. Name: ${data.roomName}, topic: ${data.roomTopic}, public: ${data.roomIsPublic}, user count: ${data.roomUserCount}, signed: ${data.roomIsSigned}`);\n});\n\n\n// Initialization code\nconsole.log(`User script loaded and ready.\\nECMA Script version: ${Graal.versionECMAScript}\\nGraal version: ${Graal.versionGraalVM}\\nHotCode: ${Graal.isGraalRuntime()}`);\n\n//\n// Helper functions follows\n//\n\n// Predicts the next F1 move using a very advanced and complex system\nfunction getF1Prediction()\n{\n\tconst drivers = ['Verstappen', 'Hamilton', 'Leclerc', 'Alonso', 'Norris', 'Russell', 'Gasly', 'Albon', 'Hadjar', 'Hülkenberg', 'Ocon', 'Tsunoda', 'Piastri', 'Antonelli', 'Stroll', 'Colapinto', 'Sainz', 'Lawson', 'Bortoleto', 'Bearman'];\n\tconst actions = ['overtakes', 'crashes into', 'blocks', 'dive bombs', 'undercuts', 'swaps position with']\n\n\treturn getRandomString(drivers) + \" \" + getRandomString(actions) + \" \" + getRandomString(drivers);\n}\n\n// Great function to help create PowerPoint slides\nfunction generateBullshit()\n{\n\tconst fle0 = [\n\t\t\"aggregate\",\n\t\t\"architect\",\n\t\t\"benchmark\",\n\t\t\"brand\",\n\t\t\"cultivate\",\n\t\t\"deliver\",\n\t\t\"deploy\",\n\t\t\"disintermediate\",\n\t\t\"drive\",\n\t\t\"e-enable\",\n\t\t\"embrace\",\n\t\t\"empower\",\n\t\t\"enable\",\n\t\t\"engage\",\n\t\t\"engineer\",\n\t\t\"enhance\",\n\t\t\"envisioneer\",\n\t\t\"evolve\",\n\t\t\"expedite\",\n\t\t\"exploit\",\n\t\t\"extend\",\n\t\t\"facilitate\",\n\t\t\"generate\",\n\t\t\"grow\",\n\t\t\"harness\",\n\t\t\"implement\",\n\t\t\"incentivize\",\n\t\t\"incubate\",\n\t\t\"innovate\",\n\t\t\"integrate\",\n\t\t\"iterate\",\n\t\t\"leverage\",\n\t\t\"matrix\",\n\t\t\"maximize\",\n\t\t\"mesh\",\n\t\t\"monetize\",\n\t\t\"morph\",\n\t\t\"optimize\",\n\t\t\"orchestrate\",\n\t\t\"productize\",\n\t\t\"recontextualize\",\n\t\t\"reintermediate\",\n\t\t\"reinvent\",\n\t\t\"repurpose\",\n\t\t\"revolutionize\",\n\t\t\"scale\",\n\t\t\"seize\",\n\t\t\"strategize\",\n\t\t\"streamline\",\n\t\t\"syndicate\",\n\t\t\"synergize\",\n\t\t\"synthesize\",\n\t\t\"target\",\n\t\t\"transform\",\n\t\t\"transition\",\n\t\t\"unleash\",\n\t\t\"utilize\",\n\t\t\"visualize\",\n\t\t\"whiteboard\"\n\t];\n\n\tconst fle1 = [\n\t\t\"24/365\",\n\t\t\"24/7\",\n\t\t\"B2B\",\n\t\t\"B2C\",\n\t\t\"back-end\",\n\t\t\"best-of-breed\",\n\t\t\"bleeding-edge\",\n\t\t\"bricks-and-clicks\",\n\t\t\"clicks-and-mortar\",\n\t\t\"collaborative\",\n\t\t\"compelling\",\n\t\t\"cross-platform\",\n\t\t\"cross-media\",\n\t\t\"customized\",\n\t\t\"cutting-edge\",\n\t\t\"distributed\",\n\t\t\"dot-com\",\n\t\t\"dynamic\",\n\t\t\"e-business\",\n\t\t\"efficient\",\n\t\t\"end-to-end\",\n\t\t\"enterprise\",\n\t\t\"extensible\",\n\t\t\"frictionless\",\n\t\t\"front-end\",\n\t\t\"global\",\n\t\t\"granular\",\n\t\t\"holistic\",\n\t\t\"impactful\",\n\t\t\"innovative\",\n\t\t\"integrated\",\n\t\t\"interactive\",\n\t\t\"intuitive\",\n\t\t\"killer\",\n\t\t\"leading-edge\",\n\t\t\"magnetic\",\n\t\t\"mission-critical\",\n\t\t\"next-generation\",\n\t\t\"one-to-one\",\n\t\t\"plug-and-play\",\n\t\t\"proactive\",\n\t\t\"real-time\",\n\t\t\"revolutionary\",\n\t\t\"robust\",\n\t\t\"scalable\",\n\t\t\"seamless\",\n\t\t\"sexy\",\n\t\t\"sticky\",\n\t\t\"strategic\",\n\t\t\"synergistic\",\n\t\t\"transparent\",\n\t\t\"turn-key\",\n\t\t\"ubiquitous\",\n\t\t\"user-centric\",\n\t\t\"value-added\",\n\t\t\"vertical\",\n\t\t\"viral\",\n\t\t\"virtual\",\n\t\t\"visionary\",\n\t\t\"web-enabled\",\n\t\t\"wireless\",\n\t\t\"world-class\"\n\t];\n\n\tconst fle2 = [\n\t\t\"action-items\",\n\t\t\"AI\",\n\t\t\"applications\",\n\t\t\"architectures\",\n\t\t\"bandwidth\",\n\t\t\"channels\",\n\t\t\"cloud\",\n\t\t\"communities\",\n\t\t\"content\",\n\t\t\"convergence\",\n\t\t\"deliverables\",\n\t\t\"e-business\",\n\t\t\"e-commerce\",\n\t\t\"e-markets\",\n\t\t\"e-services\",\n\t\t\"e-tailers\",\n\t\t\"experiences\",\n\t\t\"eyeballs\",\n\t\t\"functionalities\",\n\t\t\"infomediaries\",\n\t\t\"infrastructures\",\n\t\t\"initiatives\",\n\t\t\"interfaces\",\n\t\t\"markets\",\n\t\t\"methodologies\",\n\t\t\"metrics\",\n\t\t\"mindshare\",\n\t\t\"models\",\n\t\t\"networks\",\n\t\t\"niches\",\n\t\t\"paradigms\",\n\t\t\"partnerships\",\n\t\t\"platforms\",\n\t\t\"portals\",\n\t\t\"relationships\",\n\t\t\"ROI\",\n\t\t\"synergies\",\n\t\t\"web-readiness\",\n\t\t\"schemas\",\n\t\t\"solutions\",\n\t\t\"supply-chains\",\n\t\t\"systems\",\n\t\t\"technologies\",\n\t\t\"users\",\n\t\t\"vortals\",\n\t\t\"web services\"\n\t];\n\n\treturn getRandomString(fle0) + \" \" + getRandomString(fle1) + \" \" + getRandomString(fle2);\n}\n\nfunction getRandomString(array)\n{\n\treturn array[Math.floor(Math.random() * array.length)];\n}\n\n"
  },
  {
    "path": "scripts/bot/Dockerfile",
    "content": "FROM python:3.10-slim AS builder\n\nWORKDIR /app/\nCOPY requirements.txt ./\n\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY bot.py .\nCOPY config.json .\n\nENV PYTHONUNBUFFERED=1\n\nCMD [ \"python\", \"./bot.py\" ]"
  },
  {
    "path": "scripts/bot/README.md",
    "content": "# Xeres Bot\n\nThis is a simple python script demonstrating how to use a Xeres instance as a bot.\n\nIt is supposed to use a LLM running locally.\n\n## Installation\n\nYou need the following:\n\n- a running Xeres instance\n- a running Ollama instance (also works with llamafile)\n- `pip install requests stomp.py cachetools`\n\n## Running Xeres\n\nEither run it standalone with the `--no-gui` option or with a docker compose like that:\n\n```\nservices:\n  xeres:\n    image: zapek/xeres:0.8.0\n    user: 0:0\n    environment:\n      - SPRING_PROFILES_ACTIVE=cloud\n      - XERES_SERVER_PORT=3333\n      - XERES_DATA_DIR=/tmp/xeres\n      - XERES_HTTPS=false\n      - XERES_CONTROL_PASSWORD=false\n      - \"JAVA_TOOL_OPTIONS=-Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8\"\n    volumes:\n      xeres-bot-data:/tmp/xeres\n    mem_limit: 1G\n    restart: unless-stopped\n    network_mode: host\n    \nvolumes:\n  xeres-bot-data:\n```\n\n## Running with ollama\n\nGet ollama from here: https://ollama.com/\n\nThen use `ollama run llama2`\n\n## Running with llamafile\n\nGet llamafile from here: https://github.com/mozilla-Ocho/llamafile\n\nRun it with something like that (use the name of the llamafile you downloaded):\n\n### Windows\n\n`.\\llamafile.exe --server --port 11434 --nobrowser`\n\n### Linux\n\n`llamafile --server --port 11434 --nobrowser`\n\n### Docker\n\nSee https://github.com/iverly/llamafile-docker\n\n## Writing the configuration file\n\nYou need a `config.json` file in the same directory which looks like the following:\n\n```\n{\n    \"xeres\": {\n        \"api_url\": \"http://localhost:6232\",\n        \"profile_name\": \"YourBotName\",\n        \"location_name\": \"YourLocationName\",\n        \"friend_ids\": [\n            \"a Retroshare ID or Xeres ID of a friend's node\"\n        ],\n        \"room_names\": [\n            \"the name of a chat room to join\"\n        ]\n    },\n    \"openai\": {\n        \"api_url\": \"http://localhost:11434/v1/chat/completions\",\n        \"temperature\": 0.7,\n        \"model\": \"llama2\",\n        \"prompt\": \"You are an assistant and your name is {assistant}. You are helpful, kind, obedient, honest and know your own limits. You answer to {user}.\"\n    },\n    \"context\": {\n        \"max_users\": 256,\n        \"max_time\": 7200,\n        \"interactions\": 6\n    }\n}\n```\n\n### Running the script\n\n`python3 bot.py`\n\nIt will automatically configure the running Xeres instance and then take control of it. The bot will join the configured chat rooms and answer to users when being addressed directly.\nIt also answers to direct messages between nodes. If there's an `avatar.png` present in the same directory during configuration, it'll be used as the bot's avatar picture.\n"
  },
  {
    "path": "scripts/bot/bot.py",
    "content": "#!/usr/bin/env python3\n\n#  Copyright (c) 2024 by David Gerber - https://zapek.com\n#\n#  This file is part of Xeres.\n#\n#  Xeres is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation, either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  Xeres is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n\n\nimport json\nimport os\nimport requests\nimport stomp\nimport time\nfrom cachetools import TTLCache\nfrom urllib.parse import urlparse\n\ntry:\n\twith open('config.json', encoding=\"utf-8\") as config_file:\n\t\tconfig = json.load(config_file)\nexcept FileNotFoundError:\n\tprint(\"Missing configuration file 'config.json' in the same directory. See the README.md file for more information.\")\n\texit(1)\n\nXERES_API_URL = config['xeres']['api_url']\nXERES_API_PREFIX = \"/api/v1\"\nXERES_API_HOST = urlparse(XERES_API_URL).hostname\nXERES_API_PORT = urlparse(XERES_API_URL).port\nPROFILE_NAME = config['xeres']['profile_name']\nLOCATION_NAME = config['xeres']['location_name']\nFRIEND_IDS = config['xeres']['friend_ids']\nROOM_NAMES = config['xeres']['room_names']\n\nOPENAI_URL = config['openai']['api_url']\nTEMPERATURE = config['openai']['temperature']\nMODEL = config['openai']['model'] if 'model' in config['openai'] else None\nPROMPT = config['openai']['prompt']\nAVATAR = \"avatar.png\"\n\nCHAT_CACHE = TTLCache(config['context']['max_users'], config['context']['max_time'])\nINTERACTIONS = config['context']['interactions']\n\n\ndef has_profile():\n\tr = requests.get(XERES_API_URL + XERES_API_PREFIX + \"/profiles/1\")\n\treturn r.status_code == 200\n\n\ndef create_profile():\n\tr = requests.post(XERES_API_URL + XERES_API_PREFIX + \"/config/profile\", json={'name': PROFILE_NAME})\n\tif r.status_code != 201:\n\t\traise RuntimeError(f\"Couldn't create profile: {r.status_code}\")\n\n\ndef create_location():\n\tr = requests.post(XERES_API_URL + XERES_API_PREFIX + \"/config/location\", json={'name': LOCATION_NAME})\n\tif r.status_code != 201:\n\t\traise RuntimeError(f\"Couldn't create location: {r.status_code}\")\n\n\ndef create_identity():\n\tr = requests.post(XERES_API_URL + XERES_API_PREFIX + \"/config/identity\", json={'name': PROFILE_NAME})\n\tif r.status_code != 201:\n\t\traise RuntimeError(f\"Couldn't create identity: {r.status_code}\")\n\n\ndef get_own_profile():\n\tr = requests.get(XERES_API_URL + XERES_API_PREFIX + \"/profiles/1\")\n\tif r.status_code != 200:\n\t\traise RuntimeError(\"Couldn't get own profile\")\n\treturn json.loads(r.text)\n\n\ndef get_own_identity():\n\tr = requests.get(XERES_API_URL + XERES_API_PREFIX + \"/identities/1\")\n\tif r.status_code != 200:\n\t\traise RuntimeError(\"Couldn't get own identity\")\n\treturn json.loads(r.text)\n\n\ndef get_own_location():\n\tr = requests.get(XERES_API_URL + XERES_API_PREFIX + \"/locations/1\")\n\tif r.status_code != 200:\n\t\traise RuntimeError(\"Couldn't get own location\")\n\treturn json.loads(r.text)\n\n\ndef get_own_rsid():\n\tr = requests.get(XERES_API_URL + XERES_API_PREFIX + \"/locations/1/rs-id\")\n\tif r.status_code != 200:\n\t\traise RuntimeError(f\"Couldn't get own RsId: {r.status_code}\")\n\treturn json.loads(r.text).get(\"rsId\")\n\n\ndef add_friend(id):\n\tr = requests.post(XERES_API_URL + XERES_API_PREFIX + \"/profiles?trust=FULL\", json={'rsId': id})\n\tif r.status_code != 201:\n\t\traise RuntimeError(f\"Couldn't add friend: {r.status_code}\")\n\n\ndef synchronize_chatrooms(rooms):\n\tprint(\"Syncing chatrooms...\")\n\tremaining_rooms = rooms.copy()\n\twhile len(remaining_rooms) > 0:\n\t\tfor name in remaining_rooms:\n\t\t\tcontext = get_chat_rooms()\n\t\t\tid = find_chat_room(name, context['chatRooms']['subscribed'])\n\t\t\tif id != 0:\n\t\t\t\tremaining_rooms.remove(name)\n\t\t\t\tbreak\n\n\t\t\tid = find_chat_room(name, context['chatRooms']['available'])\n\t\t\tif id != 0:\n\t\t\t\tprint(f\"Subscribing to room {name} with id {id}\", name, id)\n\t\t\t\tr = requests.put(XERES_API_URL + XERES_API_PREFIX + \"/chat/rooms/\" + str(id) + \"/subscription\")\n\t\t\t\tif r.status_code != 200:\n\t\t\t\t\traise RuntimeError(f\"Couldn't subscribe to chatroom: {r.status_code}\")\n\t\t\t\tremaining_rooms.remove(name)\n\t\t\t\tbreak\n\t\t\ttime.sleep(10)\n\n\tcontext = get_chat_rooms()\n\tfor room in context['chatRooms']['subscribed']:\n\t\tif room['name'] not in rooms:\n\t\t\tleave_room(room['name'])\n\n\ndef get_chat_rooms():\n\tr = requests.get(XERES_API_URL + XERES_API_PREFIX + \"/chat/rooms\")\n\tif r.status_code != 200:\n\t\traise RuntimeError(f\"Couldn't get chatrooms: {r.status_code}\")\n\treturn json.loads(r.text)\n\n\ndef find_chat_room(name, room_array):\n\tfor room in room_array:\n\t\tif room['name'] == name:\n\t\t\treturn room['id']\n\treturn 0\n\n\ndef leave_room(id):\n\tr = requests.delete(XERES_API_URL + XERES_API_PREFIX + \"/chat/rooms/\" + str(id) + \"/subscription\")\n\tif r.status_code != 204:\n\t\traise RuntimeError(f\"Couldn't leave room: {r.status_code}\")\n\n\ndef upload_avatar(path):\n\twith open(path, 'rb') as img:\n\t\tfiles = [('file', (AVATAR, img, \"image/png\"))]\n\t\tr = requests.post(XERES_API_URL + XERES_API_PREFIX + \"/identities/1/image\", data={}, files=files)\n\t\tif r.status_code != 201:\n\t\t\traise RuntimeError(f\"Couldn't upload avatar: {r.status_code}\")\n\n\ndef connect_and_subscribe(conn):\n\tconn.connect(wait=True, with_connect_command=True)\n\tconn.subscribe(destination='/topic/chat/private', id=1, ack='auto')\n\tconn.subscribe(destination='/topic/chat/room', id=2, ack='auto')\n\n\nclass StompListener(stomp.ConnectionListener):\n\tdef __init__(self, conn, own_id):\n\t\tself.conn = conn\n\t\tself.own_id = own_id\n\n\tdef on_error(self, frame):\n\t\tprint('received an error \"%s\"' % frame.body)\n\n\tdef on_message(self, frame):\n\t\t# print(f'frame is {frame}')\n\t\theaders = frame.headers\n\t\tdata = json.loads(frame.body)\n\t\tif headers['messageType'] == \"CHAT_ROOM_MESSAGE\" and data['senderNickname']:\n\t\t\thandle_incoming_room_message(self.conn,\n\t\t\t                             self.own_id,\n\t\t\t                             headers['destinationId'],\n\t\t\t                             data['roomId'],\n\t\t\t                             data['senderNickname'],\n\t\t\t                             data['gxsId']['bytes'],\n\t\t\t                             data['content'])\n\t\telif headers['messageType'] == \"CHAT_PRIVATE_MESSAGE\" and data['own'] == False:\n\t\t\thandle_incoming_private_message(self.conn,\n\t\t\t                                self.own_id,\n\t\t\t                                headers['destinationId'],\n\t\t\t                                data['content'])\n\n\tdef on_disconnected(self):\n\t\tprint('disconnected')\n\n\ndef handle_chat(own_id):\n\tconn = stomp.WSStompConnection([(XERES_API_HOST, XERES_API_PORT)], ws_path='/ws')\n\tconn.set_listener('', StompListener(conn, own_id))\n\tconnect_and_subscribe(conn)\n\twhile True:\n\t\ttime.sleep(60)\n\n\ndef handle_incoming_room_message(conn, own_id, destination_id, room_id, sender, gxs_sender, content):\n\town_nickname = own_id['name'].lower()\n\n\t# We need to do that because what we say in the room is echoed back, obviously\n\tif gxs_sender == own_id['gxsId']['bytes']:\n\t\treturn\n\n\tlower_content = content.lower()\n\n\tif not (lower_content.startswith(\"@\" + own_nickname + \" \") or lower_content.startswith(own_nickname + \": \") or lower_content.startswith(\"@\" + own_nickname + \": \")):\n\t\treturn\n\n\tcontent = content[(len(own_nickname) + (3 if lower_content.startswith(\"@\" + own_nickname + \": \") else 2)):]\n\n\t# print(f\"Handling message from {sender} in {room_id}: {content}\")\n\n\tcontent = openai_api_send(content, own_id['name'], sender, gxs_sender, lambda: send_chat_room_typing_notification(conn, own_id, destination_id, room_id))\n\n\tconn.send(destination=\"/app/chat/room\",\n\t          content_type=\"application/json\",\n\t          headers={\"messageType\": \"CHAT_ROOM_MESSAGE\",\n\t                   \"destinationId\": f\"{destination_id}\"},\n\t          body=json.dumps({\"roomId\": room_id,\n\t                           \"senderNickname\": PROFILE_NAME,\n\t                           \"gxsId\": {\"bytes\": f\"{own_id['gxsId']['bytes']}\"},\n\t                           \"content\": f\"{sender}: {content}\"})\n\t          )\n\n\ndef handle_incoming_private_message(conn, own_id, destination_id, content):\n\t# user is not really the destination_id, we should fetch it\n\tcontent = openai_api_send(content, own_id['name'], destination_id, destination_id, lambda: send_private_typing_notification(conn, destination_id))\n\n\tconn.send(destination=\"/app/chat/private\",\n\t          content_type=\"application/json\",\n\t          headers={\"messageType\": \"CHAT_PRIVATE_MESSAGE\",\n\t                   \"destinationId\": f\"{destination_id}\"},\n\t          body=json.dumps({\"content\": f\"{content}\"})\n\t          )\n\n\ndef send_chat_room_typing_notification(conn, own_id, destination_id, room_id):\n\tconn.send(destination=\"/app/chat/room\",\n\t          content_type=\"application/json\",\n\t          headers={\"messageType\": \"CHAT_ROOM_TYPING_NOTIFICATION\",\n\t                   \"destinationId\": f\"{destination_id}\"},\n\t          body=json.dumps({\"roomId\": room_id,\n\t                           \"senderNickname\": PROFILE_NAME,\n\t                           \"gxsId\": {\"bytes\": f\"{own_id['gxsId']['bytes']}\"},\n\t                           \"content\": \"\"})\n\t          )\n\n\ndef send_private_typing_notification(conn, destination_id):\n\tconn.send(destination=\"/app/chat/private\",\n\t          content_type=\"application/json\",\n\t          headers={\"messageType\": \"CHAT_TYPING_NOTIFICATION\",\n\t                   \"destinationId\": f\"{destination_id}\"},\n\t          body=json.dumps({\"content\": \"\"})\n\t          )\n\n\ndef strip_nickname_prefix(message, nickname):\n\tif message.startswith(nickname + \": \"):\n\t\treturn message[(len(nickname) + 2):]\n\treturn message\n\n\ndef evict_cache(messages):\n\tif (len(messages)) > INTERACTIONS * 2:\n\t\tmessages.pop(0)\n\t\tmessages.pop(0)\n\n\ndef get_cache_messages(user_id):\n\tmessages = CHAT_CACHE.get(user_id, list())\n\tCHAT_CACHE[user_id] = messages\n\treturn messages\n\n\ndef create_query_for_openai_api(prompt, messages):\n\tmodel = {\n\t\t\"messages\": [\n\t\t\t{\n\t\t\t\t\"role\": \"system\",\n\t\t\t\t\"content\": f\"{prompt}\"\n\t\t\t}\n\t\t],\n\t\t\"temperature\": TEMPERATURE,\n\t\t\"stream\": True\n\t}\n\n\tif MODEL:\n\t\tmodel['model'] = MODEL\n\n\tidx = 0\n\n\tfor message in messages:\n\t\tmodel['messages'].append({\n\t\t\t\"role\": \"user\" if idx % 2 == 0 else \"assistant\",\n\t\t\t\"content\": f\"{message}\"\n\t\t})\n\t\tidx += 1\n\n\treturn model\n\n\n# user_id can be gxs or location_id, doesn't matter, it's for the cache\ndef openai_api_send(message, assistant, user, user_id, _callback=None):\n\tprint(f\"<{user}> {assistant}: {message}\")\n\n\tstart = time.time()\n\t_callback()\n\n\tmessages = get_cache_messages(user_id)\n\tmessages.append(message)\n\n\tprompt = PROMPT.format(assistant=assistant, user=user)\n\n\tquery = create_query_for_openai_api(prompt, messages)\n\t# print(f\"Query: {query}\")\n\n\tr = requests.post(OPENAI_URL, json=query, stream=True)\n\tif r.status_code != 200:\n\t\traise RuntimeError(f\"Couldn't send message to openai API server ({r.status_code}): {r.text}\")\n\n\toutput = \"\"\n\n\tfor line in r.iter_lines():\n\t\tif line:\n\t\t\tline = line.decode(\"utf-8\")\n\t\t\tif line == \"data: [DONE]\":\n\t\t\t\tbreak\n\t\t\tline = remove_prefix(line, \"data: \")\n\t\t\to = json.loads(line)\n\t\t\tif 'content' in o['choices'][0]['delta']:\n\t\t\t\toutput += o['choices'][0]['delta']['content']\n\t\t\tif _callback and time.time() - start > 5.0:\n\t\t\t\t_callback()\n\t\t\t\tstart = time.time()\n\n\tresponse = strip_nickname_prefix(output, assistant)  # idiot AI sometimes inserts itself in the reply\n\n\tprint(f\"<{assistant}> {user}: {response}\")\n\n\tmessages.append(response)\n\tevict_cache(messages)\n\n\treturn response\n\n\ndef remove_prefix(text, prefix):\n\tif text.startswith(prefix):\n\t\treturn text[len(prefix):]\n\treturn text\n\n\nif not has_profile():\n\tcreate_profile()\n\tcreate_location()\n\tcreate_identity()\n\tif os.path.isfile(AVATAR):\n\t\tupload_avatar(AVATAR)\n\nfor friend in FRIEND_IDS:\n\tadd_friend(friend)\n\nprint(\"Xeres Bot v1.0\\n\")\n\nown_id = get_own_identity()\nprint(f\"I am {own_id.get('name')}\")\nprint(f\"This is my RS ID (paste it in friends I have to connect to):\\n{get_own_rsid()}\")\n\nsynchronize_chatrooms(ROOM_NAMES)\nprint(\"Ready and awaiting to be addressed to.\")\n\nhandle_chat(own_id)\n"
  },
  {
    "path": "scripts/bot/requirements.txt",
    "content": "cachetools==7.1.1\ncertifi==2026.4.22\ncharset-normalizer==3.4.7\ndocopt==0.6.2\nidna==3.15\nrequests==2.34.2\nstomp.py==9.0.0\nurllib3==2.7.0\nwebsocket-client==1.9.0"
  },
  {
    "path": "scripts/helper/i18n_find_dupe.py",
    "content": "#!/usr/bin/env python3\n\n#  Copyright (c) 2026 by David Gerber - https://zapek.com\n#\n#  This file is part of Xeres.\n#\n#  Xeres is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation, either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  Xeres is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n#  This file is part of Xeres.\n#\n#  Xeres is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation, either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  Xeres is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n#\n#  This file is part of Xeres.\n#\n#  Xeres is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation, either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  Xeres is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n\n\ndef find_duplicate_lines(filename):\n\t\"\"\"\n\tFind duplicate lines in property files. Useful for checking internationalization files.\n\n\tArgs:\n\t\tfilename (str): Path to the input file\n\n\tReturns:\n\t\tdict: Dictionary with duplicate values as keys and list of line numbers as values\n\t\"\"\"\n\t# Dictionary to store values and their line numbers\n\tvalues_dict = {}\n\n\ttry:\n\t\twith open(filename, 'r') as file:\n\t\t\tfor line_num, line in enumerate(file, 1):\n\t\t\t\tline = line.strip()\n\n\t\t\t\t# Skip empty lines\n\t\t\t\tif not line:\n\t\t\t\t\tcontinue\n\n\t\t\t\t# Find the '=' sign and get the value after it\n\t\t\t\tif '=' in line:\n\t\t\t\t\t# Split on first '=' to get the value part\n\t\t\t\t\tparts = line.split('=', 1)\n\t\t\t\t\tif len(parts) == 2:\n\t\t\t\t\t\tvalue = parts[1].strip()\n\n\t\t\t\t\t\t# Store line numbers for each value\n\t\t\t\t\t\tif value not in values_dict:\n\t\t\t\t\t\t\tvalues_dict[value] = []\n\t\t\t\t\t\tvalues_dict[value].append(line_num)\n\n\texcept FileNotFoundError:\n\t\tprint(f\"Error: File '{filename}' not found.\")\n\t\treturn {}\n\texcept Exception as e:\n\t\tprint(f\"Error reading file: {e}\")\n\t\treturn {}\n\n\t# Filter to only show duplicates (values that appear more than once)\n\tduplicates = {value: lines for value, lines in values_dict.items() if len(lines) > 1}\n\n\treturn duplicates\n\n\ndef main():\n\t# Get filename from user\n\tfilename = input(\"Enter the filename: \").strip()\n\n\t# Find duplicates\n\tduplicates = find_duplicate_lines(filename)\n\n\t# Display results\n\tif duplicates:\n\t\tprint(\"\\nDuplicate values found:\")\n\t\tprint(\"-\" * 50)\n\t\tfor value, line_numbers in duplicates.items():\n\t\t\tprint(f\"Value: '{value}'\")\n\t\t\tprint(f\"Lines: {', '.join(map(str, line_numbers))}\")\n\t\t\tprint()\n\telse:\n\t\tprint(\"No duplicate values found.\")\n\n\nif __name__ == \"__main__\":\n\tmain()\n"
  },
  {
    "path": "settings.gradle",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\nplugins {\n    id 'com.gradle.develocity' version '4.4.1'\n}\n\ndevelocity {\n    buildScan {\n        publishing.onlyIf {\n            System.getenv(\"CI\") != null\n        }\n        termsOfUseUrl = \"https://gradle.com/help/legal-terms-of-use\"\n        termsOfUseAgree = \"yes\"\n        tag \"CI\"\n        uploadInBackground = false\n    }\n}\n\nrootProject.name = 'Xeres'\ninclude 'ui'\ninclude 'app'\ninclude 'common'\n"
  },
  {
    "path": "transifex.yml",
    "content": "git:\n  filters:\n  - filter_type: file\n    file_format: UNICODEPROPERTIES\n    source_language: en\n    source_file: common/src/main/resources/i18n/messages.properties\n    translation_files_expression: 'common/src/main/resources/i18n/messages_<lang>.properties'"
  },
  {
    "path": "ui/build.gradle",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nimport org.springframework.boot.gradle.plugin.SpringBootPlugin\n\n\nplugins {\n    id 'org.openjfx.javafxplugin' version '0.0.14'\n    id 'com.bakdata.mockito'\n}\n\njavafx {\n    version = \"26.0.1\"\n    modules = ['javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.media']\n}\n\ntest {\n    useJUnitPlatform()\n    test.jvmArgs \"-ea\", \"-Djava.net.preferIPv4Stack=true\", \"-Dfile.encoding=UTF-8\", \"-Djava.awt.headless=true\", \"-Dtestfx.robot=glass\", \"-Dtestfx.headless=true\", \"-Dprism.order=sw\", \"-Dprism.verbose=true\"\n}\n\njacocoTestReport {\n    reports {\n        xml.required = true\n        html.required = false\n    }\n}\n\njavadoc {\n    options.overview = \"src/main/javadoc/overview.html\"\n}\n\ndependencies {\n    implementation(platform(SpringBootPlugin.BOM_COORDINATES))\n    implementation project(':common')\n    // Always keep the following in sync with the app module\n    implementation('org.springframework.boot:spring-boot-starter-webclient') {\n        exclude group: 'io.netty', module: 'netty-transport-native-epoll'\n        exclude group: 'io.netty', module: 'netty-codec-native-quic'\n    }\n    implementation 'org.springframework.boot:spring-boot-starter-websocket'\n    implementation 'commons-io:commons-io:2.22.0'\n    implementation 'net.rgielen:javafx-weaver-spring-boot-starter:2.0.1'\n    implementation 'tools.jackson.datatype:jackson-datatype-jakarta-jsonp'\n    implementation 'org.fxmisc.flowless:flowless:0.7.4'\n    implementation \"org.apache.commons:commons-lang3:$apacheCommonsLangVersion\"\n    implementation \"org.apache.commons:commons-collections4:$apacheCommonsCollectionsVersion\"\n    implementation \"org.jsoup:jsoup:$jsoupVersion\"\n    implementation 'com.github.sarxos:webcam-capture:0.3.12'\n    implementation \"com.google.zxing:javase:$zxingVersion\"\n    implementation 'io.github.mkpaz:atlantafx-base:2.1.0'\n    implementation \"net.java.dev.jna:jna-platform:$jnaVersion\"\n    implementation platform('org.kordamp.ikonli:ikonli-bom:12.4.0')\n    implementation 'org.kordamp.ikonli:ikonli-javafx'\n    implementation 'org.kordamp.ikonli:ikonli-materialdesign2-pack'\n    implementation \"org.commonmark:commonmark:$commonMarkVersion\"\n    implementation \"org.commonmark:commonmark-ext-autolink:$commonMarkVersion\"\n    implementation \"org.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion\"\n    testImplementation \"org.junit.jupiter:junit-jupiter:$junitVersion\"\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n    testImplementation('org.springframework.boot:spring-boot-starter-test') {\n        exclude group: \"com.vaadin.external.google\", module: \"android-json\"\n    }\n    testImplementation(testFixtures(project(\":common\")))\n    testImplementation 'org.testfx:testfx-core:4.0.18'\n    testImplementation 'org.testfx:testfx-junit5:4.0.18'\n    testImplementation 'org.testfx:openjfx-monocle:21.0.2'\n    testImplementation \"com.tngtech.archunit:archunit-junit5:$archunitVersion\"\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/JavaFxApplication.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui;\n\nimport io.xeres.common.mui.MUI;\nimport io.xeres.ui.event.StageReadyEvent;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Application;\nimport javafx.application.HostServices;\nimport javafx.stage.Stage;\nimport org.springframework.boot.builder.SpringApplicationBuilder;\nimport org.springframework.context.ApplicationContextInitializer;\nimport org.springframework.context.ConfigurableApplicationContext;\nimport org.springframework.context.support.GenericApplicationContext;\n\nimport java.util.Objects;\n\n/**\n * This is only executed in UI mode (that is, without the --no-gui flag).\n */\npublic class JavaFxApplication extends Application\n{\n\tprivate ConfigurableApplicationContext springContext;\n\n\tprivate static Class<?> springApplicationClass;\n\n\tstatic void start(Class<?> springApplicationClass, String[] args)\n\t{\n\t\tJavaFxApplication.springApplicationClass = springApplicationClass;\n\t\tApplication.launch(JavaFxApplication.class, args);\n\t}\n\n\t@Override\n\tpublic void init()\n\t{\n\t\ttry\n\t\t{\n\t\t\tspringContext = new SpringApplicationBuilder()\n\t\t\t\t\t.sources(springApplicationClass)\n\t\t\t\t\t.headless(false) // JavaFX defaults to true, which is not what we want\n\t\t\t\t\t.initializers(initializers())\n\t\t\t\t\t.run(getParameters().getRaw().toArray(new String[0]));\n\t\t}\n\t\tcatch (Exception e)\n\t\t{\n\t\t\tMUI.showError(e);\n\t\t\tSystem.exit(1);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void start(Stage primaryStage)\n\t{\n\t\tObjects.requireNonNull(springContext);\n\n\t\t// This allows all JavaFX crashes to show up in the logger instead of stdout\n\t\tThread.setDefaultUncaughtExceptionHandler(JavaFxApplication::handleException);\n\n\t\tspringContext.publishEvent(new StageReadyEvent(primaryStage));\n\t}\n\n\t/**\n\t * Registers HostServices as a bean.\n\t *\n\t * @return the ApplicationContextInitializer.\n\t */\n\tprivate ApplicationContextInitializer<GenericApplicationContext> initializers()\n\t{\n\t\treturn ac -> ac.registerBean(HostServices.class, this::getHostServices);\n\t}\n\n\t@Override\n\tpublic void stop()\n\t{\n\t\tspringContext.close();\n\t}\n\n\tprivate static void handleException(Thread thread, Throwable throwable)\n\t{\n\t\tUiUtils.webAlertError(throwable);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/PrimaryStageInitializer.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui;\n\nimport io.xeres.common.events.ConnectWebSocketsEvent;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.ui.client.ProfileClient;\nimport io.xeres.ui.client.message.*;\nimport io.xeres.ui.controller.chat.ChatViewController;\nimport io.xeres.ui.event.StageReadyEvent;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClientRequestException;\nimport org.springframework.web.reactive.function.client.WebClientResponseException;\nimport reactor.core.publisher.Hooks;\n\nimport static io.xeres.common.message.MessagePath.*;\nimport static io.xeres.common.properties.StartupProperties.Property.ICONIFIED;\nimport static io.xeres.common.properties.StartupProperties.Property.UI;\n\n@Component\npublic class PrimaryStageInitializer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(PrimaryStageInitializer.class);\n\n\tprivate final WindowManager windowManager;\n\tprivate final ChatViewController chatViewController;\n\tprivate final ProfileClient profileClient;\n\tprivate final MessageClient messageClient;\n\n\tpublic PrimaryStageInitializer(WindowManager windowManager, ChatViewController chatViewController, ProfileClient profileClient, MessageClient messageClient)\n\t{\n\t\tthis.windowManager = windowManager;\n\t\tthis.chatViewController = chatViewController;\n\t\tthis.profileClient = profileClient;\n\t\tthis.messageClient = messageClient;\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(StageReadyEvent event)\n\t{\n\t\tHooks.onErrorDropped(throwable -> log.debug(\"WebClient warning: {}\", throwable.getMessage())); // Suppress Reactor's error messages\n\n\t\t// Do not exit the platform when all windows are closed.\n\t\tPlatform.setImplicitExit(false);\n\n\t\tprofileClient.getOwn()\n\t\t\t\t.doFirst(() -> Platform.runLater(() -> {\n\t\t\t\t\tif (SystemUtils.IS_OS_MAC)\n\t\t\t\t\t{\n\t\t\t\t\t\t// This is needed because of https://bugs.openjdk.org/browse/JDK-8248127\n\t\t\t\t\t\t// \"AppKit Thread\" has a null class loader which prevents resources from being loaded so\n\t\t\t\t\t\t// we have to set it. The AppKit Thread seems to be related with AWT, so as soon as either\n\t\t\t\t\t\t// a splash screen or a systray is being used, it will be there.\n\t\t\t\t\t\tif (Thread.currentThread().getContextClassLoader() == null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tThread.currentThread().setContextClassLoader(PrimaryStageInitializer.class.getClassLoader());\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\twindowManager.calculateWindowDecorationSizes(event.getStage());\n\t\t\t\t}))\n\t\t\t\t.doOnSuccess(profile -> windowManager.openMain(event.getStage(), profile, StartupProperties.getBoolean(ICONIFIED, false)))\n\t\t\t\t.doOnError(WebClientResponseException.class, e -> {\n\t\t\t\t\tif (e.getStatusCode() == HttpStatus.NOT_FOUND)\n\t\t\t\t\t{\n\t\t\t\t\t\twindowManager.openAccountCreation(event.getStage());\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.doOnError(WebClientRequestException.class, e -> UiUtils.webAlertError(e, Platform::exit))\n\t\t\t\t.subscribe();\n\t}\n\n\t@EventListener\n\tpublic void onNetworkReadyEvent(ConnectWebSocketsEvent unused)\n\t{\n\t\tif (!StartupProperties.getBoolean(UI, true))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (messageClient.isConnected())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tmessageClient\n\t\t\t\t.subscribe(chatPrivateDestination(), new PrivateChatFrameHandler(windowManager))\n\t\t\t\t.subscribe(chatRoomDestination(), new ChatRoomFrameHandler(chatViewController))\n\t\t\t\t.subscribe(chatDistantDestination(), new DistantChatFrameHandler(windowManager))\n\t\t\t\t.subscribe(chatBroadcastDestination(), new BroadcastChatFrameHandler())\n\t\t\t\t.subscribe(voipPrivateDestination(), new VoipFrameHandler(windowManager))\n\t\t\t\t.connect();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/UiStarter.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui;\n\npublic final class UiStarter\n{\n\tprivate UiStarter()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void start(Class<?> springApplicationClass, String[] args)\n\t{\n\t\tJavaFxApplication.start(springApplicationClass, args);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/BoardClient.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.board.BoardGroupDTO;\nimport io.xeres.common.dto.board.BoardMessageDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.board.UpdateBoardMessageReadRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.board.BoardGroup;\nimport io.xeres.ui.model.board.BoardMapper;\nimport io.xeres.ui.model.board.BoardMessage;\nimport io.xeres.ui.support.util.ClientUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.core.io.FileSystemResource;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.client.MultipartBodyBuilder;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.BodyInserters;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.io.File;\n\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\n\n@Component\npublic class BoardClient implements GxsGroupClient<BoardGroup>, GxsMessageClient<BoardMessage>\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic BoardClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + BOARDS_PATH)\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic Flux<BoardGroup> getGroups()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(BoardGroupDTO.class)\n\t\t\t\t.map(BoardMapper::fromDTO);\n\t}\n\n\tpublic Mono<Long> createBoardGroup(String name, String description, File image)\n\t{\n\t\tvar builder = ClientUtils.createGroupBuilder(name, description, image);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/groups\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.exchangeToMono(ClientUtils::getCreatedId);\n\t}\n\n\tpublic Mono<Void> updateBoardGroup(long groupId, String name, String description, File image, boolean updateImage)\n\t{\n\t\tvar builder = ClientUtils.createGroupBuilder(name, description, image);\n\t\tif (updateImage)\n\t\t{\n\t\t\tbuilder.part(\"updateImage\", true);\n\t\t}\n\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/groups/{groupId}\", groupId)\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<BoardGroup> getBoardGroupById(long groupId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups/{groupId}\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(BoardGroupDTO.class)\n\t\t\t\t.map(BoardMapper::fromDTO);\n\t}\n\n\t@Override\n\tpublic Mono<Integer> getUnreadCount(long groupId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups/{groupId}/unread-count\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Integer.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> subscribeToGroup(long groupId)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/groups/{groupId}/subscription\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> unsubscribeFromGroup(long groupId)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/groups/{groupId}/subscription\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/groups/{groupId}/read\")\n\t\t\t\t\t\t.queryParam(\"read\", read)\n\t\t\t\t\t\t.build(groupId))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<PaginatedResponse<BoardMessage>> getMessages(long groupId, int page, int size)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/groups/{groupId}/messages\")\n\t\t\t\t\t\t.queryParam(\"page\", page)\n\t\t\t\t\t\t.queryParam(\"size\", size)\n\t\t\t\t\t\t.queryParam(\"sort\", \"published,desc\")\n\t\t\t\t\t\t.build(groupId))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(new ParameterizedTypeReference<PaginatedResponse<BoardMessageDTO>>()\n\t\t\t\t{\n\t\t\t\t})\n\t\t\t\t.map(BoardMapper::fromDTO);\n\t}\n\n\tpublic Mono<BoardMessage> getBoardMessage(long messageId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/messages/{messageId}\", messageId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(BoardMessageDTO.class)\n\t\t\t\t.map(BoardMapper::fromDTO);\n\t}\n\n\tpublic Mono<Long> createBoardMessage(long boardId, String title, String content, String link, File image)\n\t{\n\t\tvar builder = new MultipartBodyBuilder();\n\t\tif (boardId == 0L)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"BoardId is required\");\n\t\t}\n\t\tbuilder.part(\"boardId\", boardId);\n\t\tif (StringUtils.isBlank(title))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Title is required\");\n\t\t}\n\t\tbuilder.part(\"title\", title);\n\t\tif (StringUtils.isNotBlank(content))\n\t\t{\n\t\t\tbuilder.part(\"content\", content);\n\t\t}\n\t\tif (StringUtils.isNotBlank(link))\n\t\t{\n\t\t\tbuilder.part(\"link\", link);\n\t\t}\n\t\tif (image != null)\n\t\t{\n\t\t\tbuilder.part(\"image\", new FileSystemResource(image));\n\t\t}\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/messages\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.exchangeToMono(ClientUtils::getCreatedId);\n\n\t}\n\n\tpublic Mono<Void> setBoardMessageReadState(long messageId, boolean read)\n\t{\n\t\tvar request = new UpdateBoardMessageReadRequest(messageId, read);\n\n\t\treturn webClient.patch()\n\t\t\t\t.uri(\"/messages\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ChannelClient.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.channel.ChannelGroupDTO;\nimport io.xeres.common.dto.channel.ChannelMessageDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.channel.UpdateChannelMessageReadRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.channel.ChannelFile;\nimport io.xeres.ui.model.channel.ChannelGroup;\nimport io.xeres.ui.model.channel.ChannelMapper;\nimport io.xeres.ui.model.channel.ChannelMessage;\nimport io.xeres.ui.support.util.ClientUtils;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.core.io.FileSystemResource;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.client.MultipartBodyBuilder;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.BodyInserters;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.io.File;\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\nimport static io.xeres.ui.model.channel.ChannelMapper.toChannelFileDTOs;\n\n@Component\npublic class ChannelClient implements GxsGroupClient<ChannelGroup>, GxsMessageClient<ChannelMessage>\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ChannelClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + CHANNELS_PATH)\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic Flux<ChannelGroup> getGroups()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ChannelGroupDTO.class)\n\t\t\t\t.map(ChannelMapper::fromDTO);\n\t}\n\n\tpublic Mono<Long> createChannelGroup(String name, String description, File image)\n\t{\n\t\tvar builder = ClientUtils.createGroupBuilder(name, description, image);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/groups\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.exchangeToMono(ClientUtils::getCreatedId);\n\t}\n\n\tpublic Mono<Void> updateChannelGroup(long groupId, String name, String description, File image, boolean updateImage)\n\t{\n\t\tvar builder = ClientUtils.createGroupBuilder(name, description, image);\n\t\tif (updateImage)\n\t\t{\n\t\t\tbuilder.part(\"updateImage\", true);\n\t\t}\n\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/groups/{groupId}\", groupId)\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\n\tpublic Mono<ChannelGroup> getChannelGroupById(long groupId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups/{groupId}\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ChannelGroupDTO.class)\n\t\t\t\t.map(ChannelMapper::fromDTO);\n\t}\n\n\t@Override\n\tpublic Mono<Integer> getUnreadCount(long groupId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups/{groupId}/unread-count\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Integer.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> subscribeToGroup(long groupId)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/groups/{groupId}/subscription\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> unsubscribeFromGroup(long groupId)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/groups/{groupId}/subscription\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/groups/{groupId}/read\")\n\t\t\t\t\t\t.queryParam(\"read\", read)\n\t\t\t\t\t\t.build(groupId))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<PaginatedResponse<ChannelMessage>> getMessages(long groupId, int page, int size)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/groups/{groupId}/messages\")\n\t\t\t\t\t\t.queryParam(\"page\", page)\n\t\t\t\t\t\t.queryParam(\"size\", size)\n\t\t\t\t\t\t.queryParam(\"sort\", \"published,desc\")\n\t\t\t\t\t\t.build(groupId))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(new ParameterizedTypeReference<PaginatedResponse<ChannelMessageDTO>>()\n\t\t\t\t{\n\t\t\t\t})\n\t\t\t\t.map(ChannelMapper::fromDTO);\n\t}\n\n\tpublic Mono<ChannelMessage> getChannelMessage(long messageId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/messages/{messageId}\", messageId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ChannelMessageDTO.class)\n\t\t\t\t.map(ChannelMapper::fromDTO);\n\t}\n\n\tpublic Mono<Long> createChannelMessage(long channelId, String title, String content, File image, List<ChannelFile> files, long originalId)\n\t{\n\t\tvar builder = new MultipartBodyBuilder();\n\t\tif (channelId == 0L)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"ChannelId is required\");\n\t\t}\n\t\tbuilder.part(\"channelId\", channelId);\n\t\tif (StringUtils.isBlank(title))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Title is required\");\n\t\t}\n\t\tbuilder.part(\"title\", title);\n\t\tif (StringUtils.isNotBlank(content))\n\t\t{\n\t\t\tbuilder.part(\"content\", content);\n\t\t}\n\t\tif (image != null)\n\t\t{\n\t\t\tbuilder.part(\"image\", new FileSystemResource(image));\n\t\t}\n\t\tif (CollectionUtils.isNotEmpty(files))\n\t\t{\n\t\t\tbuilder.part(\"files\", toChannelFileDTOs(files), MediaType.APPLICATION_JSON);\n\t\t}\n\t\tif (originalId != 0L)\n\t\t{\n\t\t\tbuilder.part(\"originalId\", originalId);\n\t\t}\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/messages\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(builder.build()))\n\t\t\t\t.exchangeToMono(ClientUtils::getCreatedId);\n\t}\n\n\tpublic Mono<Void> setChannelMessageReadState(long messageId, boolean read)\n\t{\n\t\tvar request = new UpdateChannelMessageReadRequest(messageId, read);\n\n\t\treturn webClient.patch()\n\t\t\t\t.uri(\"/messages\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ChatClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.chat.ChatBacklogDTO;\nimport io.xeres.common.dto.chat.ChatRoomBacklogDTO;\nimport io.xeres.common.dto.chat.ChatRoomContextDTO;\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.chat.ChatBacklog;\nimport io.xeres.common.message.chat.ChatRoomBacklog;\nimport io.xeres.common.message.chat.ChatRoomContext;\nimport io.xeres.common.rest.chat.ChatRoomVisibility;\nimport io.xeres.common.rest.chat.CreateChatRoomRequest;\nimport io.xeres.common.rest.chat.DistantChatRequest;\nimport io.xeres.common.rest.chat.InviteToChatRoomRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.chat.ChatMapper;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.model.location.LocationMapper;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.common.rest.PathConfig.CHAT_PATH;\n\n@Component\npublic class ChatClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ChatClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + CHAT_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<Void> createChatRoom(String name, String topic, ChatRoomVisibility visibility, boolean signedIdentities)\n\t{\n\t\tvar request = new CreateChatRoomRequest(name, topic, visibility, signedIdentities);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/rooms\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Location> createDistantChat(long identityId)\n\t{\n\t\tvar request = new DistantChatRequest(identityId);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/distant-chats\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(LocationDTO.class)\n\t\t\t\t.map(LocationMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> closeDistantChat(long identityId)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/distant-chats/{id}\", identityId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> joinChatRoom(long id)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/rooms/{id}/subscription\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> leaveChatRoom(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/rooms/{id}/subscription\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<ChatRoomContext> getChatRoomContext()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/rooms\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ChatRoomContextDTO.class)\n\t\t\t\t.map(ChatMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> inviteLocationsToChatRoom(long chatRoomId, Set<Location> locations)\n\t{\n\t\tvar request = new InviteToChatRoomRequest(chatRoomId, locations.stream()\n\t\t\t\t.map(Location::getLocationIdentifier)\n\t\t\t\t.map(LocationIdentifier::toString)\n\t\t\t\t.collect(Collectors.toSet()));\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/rooms/invite\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Flux<ChatRoomBacklog> getChatRoomBacklog(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/rooms/{roomId}/messages\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ChatRoomBacklogDTO.class)\n\t\t\t\t.map(ChatMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> deleteChatRoomBacklog(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/rooms/{roomId}/messages\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Flux<ChatBacklog> getChatBacklog(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/chats/{locationId}/messages\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ChatBacklogDTO.class)\n\t\t\t\t.map(ChatMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> deleteChatBacklog(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/chats/{locationId}/messages\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Flux<ChatBacklog> getDistantChatBacklog(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/distant-chats/{identityId}/messages\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ChatBacklogDTO.class)\n\t\t\t\t.map(ChatMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> deleteDistantChatBacklog(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/distant-chats/{identityId}/messages\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ConfigClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.rest.config.*;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.core.io.buffer.DataBuffer;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.BodyInserters;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.io.File;\nimport java.util.Set;\n\nimport static io.xeres.common.rest.PathConfig.CONFIG_PATH;\nimport static io.xeres.ui.support.util.ClientUtils.fromFile;\n\n@Component\npublic class ConfigClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ConfigClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + CONFIG_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<Void> createProfile(String name)\n\t{\n\t\tvar profileRequest = new OwnProfileRequest(name);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/profile\")\n\t\t\t\t.bodyValue(profileRequest)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> createLocation(String name)\n\t{\n\t\tvar locationRequest = new OwnLocationRequest(name);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/location\")\n\t\t\t\t.bodyValue(locationRequest)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> createIdentity(String name, boolean anonymous)\n\t{\n\t\tvar identityRequest = new OwnIdentityRequest(name, anonymous);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/identity\")\n\t\t\t\t.bodyValue(identityRequest)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> changeAvailability(Availability availability)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/location/availability\")\n\t\t\t\t.bodyValue(availability)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<IpAddressResponse> getExternalIpAddress()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/external-ip\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(IpAddressResponse.class);\n\t}\n\n\tpublic Mono<IpAddressResponse> getInternalIpAddress()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/internal-ip\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(IpAddressResponse.class);\n\t}\n\n\tpublic Mono<HostnameResponse> getHostname()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/hostname\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(HostnameResponse.class);\n\t}\n\n\tpublic Mono<UsernameResponse> getUsername()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/username\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(UsernameResponse.class);\n\t}\n\n\tpublic Mono<Set<String>> getCapabilities()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/capabilities\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<DataBuffer> getBackup()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/export\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(DataBuffer.class);\n\t}\n\n\tpublic Mono<Void> sendBackup(File file)\n\t{\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/import\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(fromFile(file)))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> sendRsKeyring(File file, String locationName, String password)\n\t{\n\t\treturn webClient.post()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/import-profile-from-rs\")\n\t\t\t\t\t\t.queryParam(\"locationName\", locationName)\n\t\t\t\t\t\t.queryParam(\"password\", password)\n\t\t\t\t\t\t.build())\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(fromFile(file)))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<ImportRsFriendsResponse> sendRsFriends(File file)\n\t{\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/import-friends-from-rs\")\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(fromFile(file)))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ImportRsFriendsResponse.class);\n\t}\n\n\tpublic Mono<Boolean> verifyUpdate(String filePath, byte[] signature)\n\t{\n\t\tvar request = new VerifyUpdateRequest(filePath, signature);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/verify-update\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Boolean.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ConnectionClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.rest.connection.ConnectionRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.model.profile.ProfileMapper;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.rest.PathConfig.CONNECTIONS_PATH;\n\n@Component\npublic class ConnectionClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ConnectionClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + CONNECTIONS_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Flux<Profile> getConnectedProfiles()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/profiles\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ProfileDTO.class)\n\t\t\t\t.map(ProfileMapper::fromDeepDTO);\n\t}\n\n\tpublic Mono<Void> connect(LocationIdentifier locationIdentifier, int connectionIndex)\n\t{\n\t\tvar connectionRequest = new ConnectionRequest(locationIdentifier.toString(), connectionIndex);\n\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/connect\")\n\t\t\t\t.bodyValue(connectionRequest)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ContactClient.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\n\nimport static io.xeres.common.rest.PathConfig.CONTACT_PATH;\n\n@Component\npublic class ContactClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ContactClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + CONTACT_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Flux<Contact> getContacts()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(Contact.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/FileClient.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.rest.file.FileDownloadRequest;\nimport io.xeres.common.rest.file.FileProgress;\nimport io.xeres.common.rest.file.FileSearchRequest;\nimport io.xeres.common.rest.file.FileSearchResponse;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.rest.PathConfig.FILES_PATH;\n\n@Component\npublic class FileClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic FileClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + FILES_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<FileSearchResponse> search(String name)\n\t{\n\t\tvar request = new FileSearchRequest(name);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/search\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(FileSearchResponse.class);\n\t}\n\n\tpublic Mono<Long> download(String name, Sha1Sum hash, long size, LocationIdentifier locationIdentifier)\n\t{\n\t\tvar request = new FileDownloadRequest(name, hash.toString(), size, locationIdentifier);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/download\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Long.class);\n\t}\n\n\tpublic Flux<FileProgress> getDownloads()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/downloads\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(FileProgress.class);\n\t}\n\n\tpublic Flux<FileProgress> getUploads()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/uploads\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(FileProgress.class);\n\t}\n\n\tpublic Mono<Void> removeDownload(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/downloads/{id}\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ForumClient.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.forum.ForumGroupDTO;\nimport io.xeres.common.dto.forum.ForumMessageDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.forum.CreateForumMessageRequest;\nimport io.xeres.common.rest.forum.CreateOrUpdateForumGroupRequest;\nimport io.xeres.common.rest.forum.UpdateForumMessageReadRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.forum.ForumGroup;\nimport io.xeres.ui.model.forum.ForumMapper;\nimport io.xeres.ui.model.forum.ForumMessage;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.rest.PathConfig.FORUMS_PATH;\n\n@Component\npublic class ForumClient implements GxsGroupClient<ForumGroup>, GxsMessageClient<ForumMessage>\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ForumClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + FORUMS_PATH)\n\t\t\t\t.build();\n\t}\n\n\t@Override\n\tpublic Flux<ForumGroup> getGroups()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ForumGroupDTO.class)\n\t\t\t\t.map(ForumMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> createForumGroup(String name, String description)\n\t{\n\t\tvar request = new CreateOrUpdateForumGroupRequest(name, description);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/groups\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> updateForumGroup(long groupId, String name, String description)\n\t{\n\t\tvar request = new CreateOrUpdateForumGroupRequest(name, description);\n\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/groups/{groupId}\", groupId)\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<ForumGroup> getForumGroupById(long groupId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups/{groupId}\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ForumGroupDTO.class)\n\t\t\t\t.map(ForumMapper::fromDTO);\n\t}\n\n\t@Override\n\tpublic Mono<Integer> getUnreadCount(long groupId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/groups/{groupId}/unread-count\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Integer.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> subscribeToGroup(long groupId)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/groups/{groupId}/subscription\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> unsubscribeFromGroup(long groupId)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/groups/{groupId}/subscription\", groupId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<Void> setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/groups/{groupId}/read\")\n\t\t\t\t\t\t.queryParam(\"read\", read)\n\t\t\t\t\t\t.build(groupId))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\t@Override\n\tpublic Mono<PaginatedResponse<ForumMessage>> getMessages(long groupId, int page, int size)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/groups/{groupId}/messages\")\n\t\t\t\t\t\t.queryParam(\"page\", page)\n\t\t\t\t\t\t.queryParam(\"size\", size)\n\t\t\t\t\t\t.queryParam(\"sort\", \"published,desc\")\n\t\t\t\t\t\t.build(groupId))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(new ParameterizedTypeReference<PaginatedResponse<ForumMessageDTO>>()\n\t\t\t\t{\n\t\t\t\t})\n\t\t\t\t.map(ForumMapper::fromDTO);\n\t}\n\n\tpublic Mono<ForumMessage> getForumMessage(long messageId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/messages/{messageId}\", messageId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ForumMessageDTO.class)\n\t\t\t\t.map(ForumMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> createForumMessage(long forumId, String title, String content, long parentId, long originalId)\n\t{\n\t\tvar request = new CreateForumMessageRequest(forumId, title, content, parentId, originalId);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/messages\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> setForumMessageReadState(long messageId, boolean read)\n\t{\n\t\tvar request = new UpdateForumMessageReadRequest(messageId, read);\n\n\t\treturn webClient.patch()\n\t\t\t\t.uri(\"/messages\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/GeneralClient.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Mono;\n\n/**\n * A WebClient that has no specific API root and is not restricted to one domain in\n * particular.\n * <p>\n * You should use domain related web clients when possible.\n */\n@Component\npublic class GeneralClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic GeneralClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl())\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<byte[]> getImage(String path)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(path)\n\t\t\t\t.accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG, MediaType.parseMediaType(\"image/webp\"))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(byte[].class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/GeoIpClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.geoip.CountryResponse;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.rest.PathConfig.GEOIP_PATH;\n\n@Component\npublic class GeoIpClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic GeoIpClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + GEOIP_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<CountryResponse> getIsoCountry(String ip)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{ip}\", ip)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(CountryResponse.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/GxsGroupClient.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\npublic interface GxsGroupClient<T>\n{\n\tFlux<T> getGroups();\n\n\tMono<Integer> getUnreadCount(long groupId);\n\n\tMono<Void> subscribeToGroup(long groupId);\n\n\tMono<Void> unsubscribeFromGroup(long groupId);\n\n\tMono<Void> setGroupMessagesReadState(long groupId, boolean read);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/GxsMessageClient.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport reactor.core.publisher.Mono;\n\npublic interface GxsMessageClient<T>\n{\n\tMono<PaginatedResponse<T>> getMessages(long groupId, int page, int size);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/IdentityClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.identity.IdentityDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.identity.Identity;\nimport io.xeres.ui.model.identity.IdentityMapper;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.BodyInserters;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.io.File;\n\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\nimport static io.xeres.ui.support.util.ClientUtils.fromFile;\n\n@Component\npublic class IdentityClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic IdentityClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + IDENTITIES_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Flux<Identity> getIdentities()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(IdentityDTO.class)\n\t\t\t\t.map(IdentityMapper::fromDTO);\n\t}\n\n\tpublic Mono<Identity> findById(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(IdentityDTO.class)\n\t\t\t\t.map(IdentityMapper::fromDTO);\n\t}\n\n\tpublic Flux<Identity> findByGxsId(GxsId gxsId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"\")\n\t\t\t\t\t\t.queryParam(\"gxsId\", gxsId.toString())\n\t\t\t\t\t\t.build())\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(IdentityDTO.class)\n\t\t\t\t.map(IdentityMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> uploadIdentityImage(long id, File file)\n\t{\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/{id}/image\", id)\n\t\t\t\t.contentType(MediaType.MULTIPART_FORM_DATA)\n\t\t\t\t.body(BodyInserters.fromMultipartData(fromFile(file)))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> deleteIdentityImage(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/{id}/image\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/LocationClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.location.RSIdResponse;\nimport io.xeres.common.rsid.Type;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.model.location.LocationMapper;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.rest.PathConfig.LOCATIONS_PATH;\n\n@Component\npublic class LocationClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic LocationClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + LOCATIONS_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<Location> findById(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(LocationDTO.class)\n\t\t\t\t.map(LocationMapper::fromDTO);\n\t}\n\n\tpublic Mono<RSIdResponse> getRSId(long id, Type type)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"/{id}/rs-id\")\n\t\t\t\t\t\t.queryParam(\"type\", type)\n\t\t\t\t\t\t.build(id))\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(RSIdResponse.class);\n\t}\n\n\tpublic Mono<Void> isServiceSupported(long id, int serviceId)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}/service/{serviceId}\", id, serviceId)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/NotificationClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.notification.availability.AvailabilityChange;\nimport io.xeres.common.rest.notification.board.BoardNotification;\nimport io.xeres.common.rest.notification.channel.ChannelNotification;\nimport io.xeres.common.rest.notification.contact.ContactNotification;\nimport io.xeres.common.rest.notification.file.FileNotification;\nimport io.xeres.common.rest.notification.file.FileSearchNotification;\nimport io.xeres.common.rest.notification.file.FileTrendNotification;\nimport io.xeres.common.rest.notification.forum.ForumNotification;\nimport io.xeres.common.rest.notification.status.StatusNotification;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.codec.ServerSentEvent;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\n\nimport static io.xeres.common.rest.PathConfig.NOTIFICATIONS_PATH;\n\n@Component\npublic class NotificationClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic NotificationClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + NOTIFICATIONS_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Flux<ServerSentEvent<StatusNotification>> getStatusNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/status\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<ForumNotification>> getForumNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/forum\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\n\tpublic Flux<ServerSentEvent<BoardNotification>> getBoardNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/board\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<ChannelNotification>> getChannelNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/channel\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<FileNotification>> getFileNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/file\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<FileSearchNotification>> getFileSearchNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/file-search\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<FileTrendNotification>> getFileTrendNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/file-trend\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<ContactNotification>> getContactNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/contact\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n\n\tpublic Flux<ServerSentEvent<AvailabilityChange>> getAvailabilityNotifications()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/availability\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(new ParameterizedTypeReference<>()\n\t\t\t\t{\n\t\t\t\t});\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/PaginatedResponse.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport org.apache.commons.collections4.ListUtils;\n\nimport java.util.List;\n\n/**\n * Paginated response.\n *\n * @param content          the page content as a {@link List}\n * @param page             the page values\n * @param <T>              the element's type\n */\npublic record PaginatedResponse<T>(\n\t\tList<T> content,\n\t\tPaginatedPage page\n)\n{\n\t/**\n\t * The paginated response page values.\n\t *\n\t * @param totalElements the total amount of elements\n\t * @param totalPages    the number of total pages\n\t * @param number        the number of the current page. Is always non-negative.\n\t * @param size          the size of the page\n\t */\n\tpublic record PaginatedPage(int totalElements,\n\t                            int totalPages,\n\t                            int number,\n\t                            int size)\n\t{\n\t}\n\n\t/**\n\t * Checks if the page has any content at all.\n\t *\n\t * @return true if the page has no content at all\n\t */\n\tpublic boolean empty()\n\t{\n\t\treturn ListUtils.emptyIfNull(content).isEmpty();\n\t}\n\n\t/**\n\t * Checks if the page is the first one.\n\t *\n\t * @return true if the page is the first one\n\t */\n\tpublic boolean first()\n\t{\n\t\treturn page.number == 0;\n\t}\n\n\t/**\n\t * Checks if the page is the last one.\n\t *\n\t * @return true if the page is the last one\n\t */\n\tpublic boolean last()\n\t{\n\t\treturn page.number == page.totalPages;\n\t}\n\n\t/**\n\t * Gets the number of elements in the page.\n\t *\n\t * @return the number of elements in the current page.\n\t */\n\tpublic int numberOfElements()\n\t{\n\t\treturn ListUtils.emptyIfNull(content).size();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ProfileClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.rest.profile.ProfileKeyAttributes;\nimport io.xeres.common.rest.profile.RsIdRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.model.profile.ProfileMapper;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID;\nimport static io.xeres.common.rest.PathConfig.PROFILES_PATH;\n\n@Component\npublic class ProfileClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic ProfileClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + PROFILES_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<Void> create(String rsId, int connectionIndex, Trust trust)\n\t{\n\t\tvar rsIdRequest = new RsIdRequest(rsId);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"\")\n\t\t\t\t\t\t.queryParam(\"connectionIndex\", connectionIndex)\n\t\t\t\t\t\t.queryParam(\"trust\", trust.name())\n\t\t\t\t\t\t.build())\n\t\t\t\t.bodyValue(rsIdRequest)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Flux<Profile> findAll()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ProfileDTO.class)\n\t\t\t\t.map(ProfileMapper::fromDTO);\n\t}\n\n\tpublic Mono<Profile> getOwn()\n\t{\n\t\treturn findById(OWN_PROFILE_ID);\n\t}\n\n\tpublic Mono<Profile> checkRsId(String rsId)\n\t{\n\t\tvar rsIdRequest = new RsIdRequest(rsId);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/check\")\n\t\t\t\t.bodyValue(rsIdRequest)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ProfileDTO.class)\n\t\t\t\t.map(ProfileMapper::fromDeepDTO);\n\t}\n\n\tpublic Mono<Profile> findById(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ProfileDTO.class)\n\t\t\t\t.map(ProfileMapper::fromDeepDTO);\n\t}\n\n\tpublic Mono<ProfileKeyAttributes> findProfileKeyAttributes(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}/key-attributes\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ProfileKeyAttributes.class);\n\t}\n\n\tpublic Flux<Contact> findContactsForProfile(long id)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/{id}/contacts\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(Contact.class);\n\t}\n\n\tpublic Flux<Profile> findByLocationIdentifier(LocationIdentifier locationIdentifier, boolean withLocations)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"\")\n\t\t\t\t\t\t.queryParam(\"locationIdentifier\", locationIdentifier.toString())\n\t\t\t\t\t\t.queryParam(\"withLocations\", withLocations)\n\t\t\t\t\t\t.build())\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ProfileDTO.class)\n\t\t\t\t.map(ProfileMapper::fromDeepDTO);\n\t}\n\n\tpublic Flux<Profile> findByPgpIdentifier(long pgpIdentifier, boolean withLocations)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(uriBuilder -> uriBuilder\n\t\t\t\t\t\t.path(\"\")\n\t\t\t\t\t\t.queryParam(\"pgpIdentifier\", Long.toUnsignedString(pgpIdentifier, 16))\n\t\t\t\t\t\t.queryParam(\"withLocations\", withLocations)\n\t\t\t\t\t\t.build())\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ProfileDTO.class)\n\t\t\t\t.map(ProfileMapper::fromDeepDTO);\n\t}\n\n\tpublic Mono<Void> setTrust(long id, Trust trust)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"/{id}/trust\", id)\n\t\t\t\t.bodyValue(trust)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<Void> delete(long id)\n\t{\n\t\treturn webClient.delete()\n\t\t\t\t.uri(\"/{id}\", id)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/SettingsClient.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.settings.SettingsDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.model.settings.SettingsMapper;\nimport jakarta.json.Json;\nimport jakarta.json.JsonValue;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Mono;\nimport tools.jackson.databind.ObjectMapper;\n\nimport static io.xeres.common.rest.PathConfig.SETTINGS_PATH;\n\n@Component\npublic class SettingsClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\tprivate final ObjectMapper objectMapper;\n\n\tprivate WebClient webClient;\n\n\tpublic SettingsClient(WebClient.Builder webClientBuilder, ObjectMapper objectMapper)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t\tthis.objectMapper = objectMapper;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + SETTINGS_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<Settings> getSettings()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(SettingsDTO.class)\n\t\t\t\t.map(SettingsMapper::fromDTO);\n\t}\n\n\tpublic Mono<Settings> patchSettings(Settings originalSettings, Settings newSettings)\n\t{\n\t\tvar target = objectMapper.convertValue(newSettings, JsonValue.class);\n\t\tvar source = objectMapper.convertValue(originalSettings, JsonValue.class);\n\t\tvar patch = Json.createDiff(source.asJsonObject(), target.asJsonObject());\n\n\t\treturn webClient.patch()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.contentType(MediaType.valueOf(\"application/json-patch+json\"))\n\t\t\t\t.bodyValue(patch)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(SettingsDTO.class)\n\t\t\t\t.map(SettingsMapper::fromDTO);\n\t}\n\n\tpublic Mono<Settings> putSettings(Settings newSettings)\n\t{\n\t\treturn webClient.put()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.bodyValue(newSettings)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(SettingsDTO.class)\n\t\t\t\t.map(SettingsMapper::fromDTO);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/ShareClient.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.dto.share.ShareDTO;\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.share.TemporaryShareRequest;\nimport io.xeres.common.rest.share.TemporaryShareResponse;\nimport io.xeres.common.rest.share.UpdateShareRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.share.Share;\nimport io.xeres.ui.model.share.ShareMapper;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.util.List;\n\nimport static io.xeres.common.rest.PathConfig.SHARES_PATH;\n\n@Component\npublic class ShareClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\n\tpublic ShareClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + SHARES_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Flux<Share> findAll()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(ShareDTO.class)\n\t\t\t\t.map(ShareMapper::fromDTO);\n\t}\n\n\tpublic Mono<Void> createAndUpdate(List<Share> shares)\n\t{\n\t\tvar request = new UpdateShareRequest(ShareMapper.toDTOs(shares));\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(Void.class);\n\t}\n\n\tpublic Mono<TemporaryShareResponse> createTemporaryShare(String filePath)\n\t{\n\t\tvar request = new TemporaryShareRequest(filePath);\n\n\t\treturn webClient.post()\n\t\t\t\t.uri(\"/temporary\")\n\t\t\t\t.bodyValue(request)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(TemporaryShareResponse.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/StatisticsClient.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.common.rest.statistics.DataCounterStatisticsResponse;\nimport io.xeres.common.rest.statistics.RttStatisticsResponse;\nimport io.xeres.common.rest.statistics.TurtleStatisticsResponse;\nimport io.xeres.common.util.RemoteUtils;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Mono;\n\nimport static io.xeres.common.rest.PathConfig.STATISTICS_PATH;\n\n@Component\npublic class StatisticsClient\n{\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic StatisticsClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(RemoteUtils.getControlUrl() + STATISTICS_PATH)\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<TurtleStatisticsResponse> getTurtleStatistics()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/turtle\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(TurtleStatisticsResponse.class);\n\t}\n\n\tpublic Mono<RttStatisticsResponse> getRttStatistics()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/rtt\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(RttStatisticsResponse.class);\n\t}\n\n\tpublic Mono<DataCounterStatisticsResponse> getDataCounterStatistics()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/data-counter\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(DataCounterStatisticsResponse.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/BroadcastChatFrameHandler.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.xeres.common.message.MessageType;\nimport javafx.application.Platform;\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\nimport org.springframework.messaging.simp.stomp.StompHeaders;\n\nimport java.lang.reflect.Type;\n\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\n\n/**\n * This handles the incoming broadcast messages from the server to the UI.\n * XXX: not used yet\n */\npublic class BroadcastChatFrameHandler implements StompFrameHandler\n{\n\t/**\n\t * Gets the payload type. It's not possible to use null or new Object(). It has to be a class\n\t * that is serializable by jackson.\n\t *\n\t * @param headers the headers\n\t * @return a type\n\t */\n\t@Override\n\tpublic Type getPayloadType(StompHeaders headers)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\treturn switch (messageType)\n\t\t{\n\t\t\tcase CHAT_BROADCAST_MESSAGE -> Void.class;\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t};\n\t}\n\n\t@Override\n\tpublic void handleFrame(StompHeaders headers, Object payload)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\tPlatform.runLater(() -> {\n\t\t\t\t\tswitch (messageType)\n\t\t\t\t\t{\n\t\t\t\t\t\tcase CHAT_BROADCAST_MESSAGE ->\n\t\t\t\t\t\t{ /* handled as a notification */ }\n\t\t\t\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/ChatRoomFrameHandler.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.*;\nimport io.xeres.ui.controller.chat.ChatViewController;\nimport javafx.application.Platform;\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\nimport org.springframework.messaging.simp.stomp.StompHeaders;\n\nimport java.lang.reflect.Type;\nimport java.util.Objects;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\n\n/**\n * This handles the incoming chat room messages from the server to the UI.\n */\npublic class ChatRoomFrameHandler implements StompFrameHandler\n{\n\tprivate final ChatViewController chatViewController;\n\n\tpublic ChatRoomFrameHandler(ChatViewController chatViewController)\n\t{\n\t\tthis.chatViewController = chatViewController;\n\t}\n\n\t/**\n\t * Gets the payload type. It's not possible to use null or new Object(). It has to be a class\n\t * that is serializable by jackson.\n\t *\n\t * @param headers the headers\n\t * @return a type\n\t */\n\t@Override\n\tpublic Type getPayloadType(StompHeaders headers)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\treturn switch (messageType)\n\t\t{\n\t\t\tcase CHAT_ROOM_JOIN, CHAT_ROOM_LEAVE, CHAT_ROOM_MESSAGE, CHAT_ROOM_TYPING_NOTIFICATION -> ChatRoomMessage.class;\n\t\t\tcase CHAT_ROOM_LIST -> ChatRoomLists.class;\n\t\t\tcase CHAT_ROOM_USER_JOIN, CHAT_ROOM_USER_LEAVE, CHAT_ROOM_USER_KEEP_ALIVE -> ChatRoomUserEvent.class;\n\t\t\tcase CHAT_ROOM_USER_TIMEOUT -> ChatRoomTimeoutEvent.class;\n\t\t\tcase CHAT_ROOM_INVITE -> ChatRoomInviteEvent.class;\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t};\n\t}\n\n\t@Override\n\tpublic void handleFrame(StompHeaders headers, Object payload)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\tPlatform.runLater(() -> {\n\t\t\t\t\tswitch (messageType)\n\t\t\t\t\t{\n\t\t\t\t\t\tcase CHAT_ROOM_MESSAGE, CHAT_ROOM_TYPING_NOTIFICATION -> chatViewController.showMessage(getChatRoomMessage(headers, payload));\n\t\t\t\t\t\tcase CHAT_ROOM_JOIN -> chatViewController.roomJoined(getRoomId(headers));\n\t\t\t\t\t\tcase CHAT_ROOM_LEAVE -> chatViewController.roomLeft(getRoomId(headers));\n\t\t\t\t\t\tcase CHAT_ROOM_LIST -> chatViewController.addRooms((ChatRoomLists) payload);\n\t\t\t\t\t\tcase CHAT_ROOM_USER_JOIN -> chatViewController.userJoined(getRoomId(headers), (ChatRoomUserEvent) payload);\n\t\t\t\t\t\tcase CHAT_ROOM_USER_LEAVE -> chatViewController.userLeft(getRoomId(headers), (ChatRoomUserEvent) payload);\n\t\t\t\t\t\tcase CHAT_ROOM_USER_KEEP_ALIVE -> chatViewController.userKeepAlive(getRoomId(headers), (ChatRoomUserEvent) payload);\n\t\t\t\t\t\tcase CHAT_ROOM_USER_TIMEOUT -> chatViewController.userTimeout(getRoomId(headers), (ChatRoomTimeoutEvent) payload);\n\t\t\t\t\t\tcase CHAT_ROOM_INVITE -> chatViewController.openInvite(getRoomId(headers), (ChatRoomInviteEvent) payload);\n\t\t\t\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t);\n\t}\n\n\tprivate static ChatRoomMessage getChatRoomMessage(StompHeaders headers, Object payload)\n\t{\n\t\tvar chatRoomMessage = (ChatRoomMessage) payload;\n\t\tchatRoomMessage.setRoomId(Long.parseLong(Objects.requireNonNull(headers.getFirst(DESTINATION_ID))));\n\t\treturn chatRoomMessage;\n\t}\n\n\tprivate static long getRoomId(StompHeaders headers)\n\t{\n\t\treturn Long.parseLong(Objects.requireNonNull(headers.getFirst(DESTINATION_ID)));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/DistantChatFrameHandler.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\nimport org.springframework.messaging.simp.stomp.StompHeaders;\n\nimport java.lang.reflect.Type;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\n\n/**\n * This handles the incoming distant chat messages from the server to the UI.\n */\npublic class DistantChatFrameHandler implements StompFrameHandler\n{\n\tprivate final WindowManager windowManager;\n\n\tpublic DistantChatFrameHandler(WindowManager windowManager)\n\t{\n\t\tthis.windowManager = windowManager;\n\t}\n\n\t/**\n\t * Gets the payload type. It's not possible to use null or new Object(). It has to be a class\n\t * that is serializable by jackson.\n\t *\n\t * @param headers the headers\n\t * @return a type\n\t */\n\t@Override\n\tpublic Type getPayloadType(StompHeaders headers)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\treturn switch (messageType)\n\t\t{\n\t\t\tcase CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> ChatMessage.class;\n\t\t\tcase CHAT_AVAILABILITY -> Availability.class;\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t};\n\t}\n\n\t@Override\n\tpublic void handleFrame(StompHeaders headers, Object payload)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\tPlatform.runLater(() -> {\n\t\t\t\t\tswitch (messageType)\n\t\t\t\t\t{\n\t\t\t\t\t\tcase CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> windowManager.openMessaging(GxsId.fromString(headers.getFirst(DESTINATION_ID)), (ChatMessage) payload);\n\t\t\t\t\t\tcase CHAT_AVAILABILITY -> windowManager.sendMessaging(headers.getFirst(DESTINATION_ID), (Availability) payload);\n\t\t\t\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/MessageClient.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.netty.handler.ssl.util.InsecureTrustManagerFactory;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.message.voip.VoipMessage;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.util.RemoteUtils;\nimport jakarta.websocket.ContainerProvider;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.messaging.MessageDeliveryException;\nimport org.springframework.messaging.converter.JacksonJsonMessageConverter;\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\nimport org.springframework.messaging.simp.stomp.StompHeaders;\nimport org.springframework.messaging.simp.stomp.StompSession;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.socket.WebSocketHttpHeaders;\nimport org.springframework.web.socket.client.standard.StandardWebSocketClient;\nimport org.springframework.web.socket.messaging.WebSocketStompClient;\nimport tools.jackson.databind.json.JsonMapper;\n\nimport javax.net.ssl.SSLContext;\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.concurrent.ExecutionException;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\nimport static io.xeres.common.message.MessagePath.*;\nimport static io.xeres.common.message.MessageType.*;\nimport static io.xeres.common.message.MessagingConfiguration.MAXIMUM_MESSAGE_SIZE;\n\n/**\n * This sends messages to the server.\n */\n@Component\npublic class MessageClient\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(MessageClient.class);\n\n\tprivate SessionHandler sessionHandler;\n\tprivate StompSession stompSession;\n\tprivate String username;\n\tprivate String password;\n\n\tprivate final List<PendingSubscription> pendingSubscriptions = new ArrayList<>();\n\tprivate final List<StompSession.Subscription> subscriptions = new ArrayList<>();\n\n\tprivate final JsonMapper jsonMapper;\n\n\tpublic MessageClient(JsonMapper jsonMapper)\n\t{\n\t\tthis.jsonMapper = jsonMapper;\n\t}\n\n\tpublic MessageClient connect()\n\t{\n\t\tvar useHttps = StartupProperties.getBoolean(StartupProperties.Property.HTTPS, true);\n\n\t\tvar url = (useHttps ? \"wss://\" : \"ws://\") + RemoteUtils.getHostnameAndPort() + \"/ws\";\n\n\t\tvar container = ContainerProvider.getWebSocketContainer();\n\t\tcontainer.setDefaultMaxTextMessageBufferSize(MAXIMUM_MESSAGE_SIZE);\n\t\tcontainer.setDefaultMaxBinaryMessageBufferSize(MAXIMUM_MESSAGE_SIZE);\n\n\t\tvar client = new StandardWebSocketClient(container);\n\n\t\tif (useHttps)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar sslContext = SSLContext.getInstance(\"TLS\");\n\t\t\t\tsslContext.init(null, InsecureTrustManagerFactory.INSTANCE.getTrustManagers(), null);\n\n\t\t\t\tclient.setSslContext(sslContext);\n\t\t\t}\n\t\t\tcatch (KeyManagementException | NoSuchAlgorithmException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t}\n\n\t\tvar stompClient = new WebSocketStompClient(client);\n\t\tstompClient.setMessageConverter(new JacksonJsonMessageConverter(jsonMapper));\n\t\tstompClient.setInboundMessageSizeLimit(MAXIMUM_MESSAGE_SIZE);\n\n\t\tvar httpHeaders = new WebSocketHttpHeaders();\n\t\tif (password != null)\n\t\t{\n\t\t\thttpHeaders.setBasicAuth(username, password);\n\t\t}\n\n\t\tsessionHandler = new SessionHandler(stompClient, url, httpHeaders, session ->\n\t\t{\n\t\t\tstompSession = session;\n\t\t\tperformPendingSubscriptions(stompSession);\n\t\t});\n\n\t\tlog.debug(\"Connecting to {}\", url);\n\n\t\tsessionHandler.connect();\n\n\t\treturn this;\n\t}\n\n\tpublic void setAuthentication(String username, String password)\n\t{\n\t\tthis.username = username;\n\t\tthis.password = password;\n\t}\n\n\tpublic MessageClient subscribe(String path, StompFrameHandler frameHandler)\n\t{\n\t\tpendingSubscriptions.add(new PendingSubscription(path, frameHandler));\n\n\t\tif (stompSession != null)\n\t\t{\n\t\t\tperformPendingSubscriptions(stompSession);\n\t\t}\n\t\treturn this;\n\t}\n\n\tpublic boolean isConnected()\n\t{\n\t\treturn stompSession != null && stompSession.isConnected();\n\t}\n\n\tpublic void sendToDestination(Identifier identifier, ChatMessage message)\n\t{\n\t\tObjects.requireNonNull(stompSession);\n\n\t\tswitch (identifier)\n\t\t{\n\t\t\tcase LocationIdentifier locationIdentifier -> sendToLocation(locationIdentifier, message);\n\t\t\tcase GxsId gxsId -> sendToGxsId(gxsId, message);\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + identifier);\n\t\t}\n\t}\n\n\tpublic void sendToDestination(Identifier identifier, VoipMessage message)\n\t{\n\t\tObjects.requireNonNull(stompSession);\n\n\t\tswitch (identifier)\n\t\t{\n\t\t\tcase LocationIdentifier locationIdentifier -> sendToLocation(locationIdentifier, message);\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + identifier);\n\t\t}\n\t}\n\n\tprivate void sendToLocation(LocationIdentifier locationIdentifier, ChatMessage message)\n\t{\n\t\tvar headers = new StompHeaders();\n\t\theaders.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_PRIVATE_DESTINATION);\n\t\theaders.set(MESSAGE_TYPE, message.isEmpty() ? CHAT_TYPING_NOTIFICATION.name() : CHAT_PRIVATE_MESSAGE.name());\n\t\theaders.set(DESTINATION_ID, locationIdentifier.toString());\n\t\tstompSession.send(headers, message);\n\t}\n\n\tprivate void sendToLocation(LocationIdentifier locationIdentifier, VoipMessage message)\n\t{\n\t\tvar headers = new StompHeaders();\n\t\theaders.setDestination(APP_PREFIX + VOIP_ROOT + VOIP_PRIVATE_DESTINATION);\n\t\theaders.set(DESTINATION_ID, locationIdentifier.toString());\n\t\tstompSession.send(headers, message);\n\t}\n\n\tprivate void sendToGxsId(GxsId gxsId, ChatMessage message)\n\t{\n\t\tvar headers = new StompHeaders();\n\t\theaders.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_DISTANT_DESTINATION);\n\t\theaders.set(MESSAGE_TYPE, message.isEmpty() ? CHAT_TYPING_NOTIFICATION.name() : CHAT_PRIVATE_MESSAGE.name());\n\t\theaders.set(DESTINATION_ID, gxsId.toString());\n\t\tstompSession.send(headers, message);\n\t}\n\n\tpublic void requestAvatar(Identifier identifier)\n\t{\n\t\tObjects.requireNonNull(stompSession);\n\n\t\tswitch (identifier)\n\t\t{\n\t\t\tcase LocationIdentifier locationIdentifier -> requestAvatarFromLocation(locationIdentifier);\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + identifier);\n\t\t}\n\t}\n\n\tprivate void requestAvatarFromLocation(LocationIdentifier locationIdentifier)\n\t{\n\t\tvar headers = new StompHeaders();\n\t\theaders.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_PRIVATE_DESTINATION);\n\t\theaders.set(MESSAGE_TYPE, CHAT_AVATAR.name());\n\t\theaders.set(DESTINATION_ID, locationIdentifier.toString());\n\t\tstompSession.send(headers, new ChatMessage());\n\t}\n\n\tpublic void sendToChatRoom(long chatRoomId, ChatMessage message)\n\t{\n\t\tObjects.requireNonNull(stompSession);\n\n\t\tvar headers = new StompHeaders();\n\t\theaders.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_ROOM_DESTINATION);\n\t\theaders.set(MESSAGE_TYPE, message.isEmpty() ? CHAT_ROOM_TYPING_NOTIFICATION.name() : CHAT_ROOM_MESSAGE.name());\n\t\theaders.set(DESTINATION_ID, String.valueOf(chatRoomId));\n\t\tstompSession.send(headers, message);\n\t}\n\n\tpublic void sendBroadcast(ChatMessage message)\n\t{\n\t\tObjects.requireNonNull(stompSession);\n\n\t\tvar headers = new StompHeaders();\n\t\theaders.setDestination(APP_PREFIX + CHAT_ROOT + CHAT_BROADCAST_DESTINATION);\n\t\theaders.set(MESSAGE_TYPE, CHAT_BROADCAST_MESSAGE.name());\n\t\tstompSession.send(headers, message);\n\t}\n\n\tprivate void performPendingSubscriptions(StompSession session)\n\t{\n\t\tlog.debug(\"Performing subscriptions...\");\n\t\twhile (!pendingSubscriptions.isEmpty())\n\t\t{\n\t\t\tvar pendingSubscription = pendingSubscriptions.removeFirst();\n\n\t\t\tvar subscription = session.subscribe(pendingSubscription.getPath(), pendingSubscription.getStompFrameHandler());\n\t\t\tsubscriptions.add(subscription);\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored) // we don't use @PreDestroy because the tomcat context is closed before that\n\t{\n\t\tif (sessionHandler != null && sessionHandler.getFuture() != null)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tsubscriptions.forEach(StompSession.Subscription::unsubscribe); // if the connection is already closed (likely when running on the same host), we catch the MessageDeliveryException below as well as IllegalStateException\n\t\t\t\tsessionHandler.getFuture().get().disconnect();\n\t\t\t}\n\t\t\tcatch (MessageDeliveryException | IllegalStateException | ExecutionException _)\n\t\t\t{\n\t\t\t\t// Nothing we can do\n\t\t\t}\n\t\t\tcatch (InterruptedException _)\n\t\t\t{\n\t\t\t\tThread.currentThread().interrupt();\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/PendingSubscription.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\n\npublic class PendingSubscription\n{\n\tprivate final String path;\n\tprivate final StompFrameHandler stompFrameHandler;\n\n\tPendingSubscription(String path, StompFrameHandler stompFrameHandler)\n\t{\n\t\tthis.path = path;\n\t\tthis.stompFrameHandler = stompFrameHandler;\n\t}\n\n\tpublic String getPath()\n\t{\n\t\treturn path;\n\t}\n\n\tpublic StompFrameHandler getStompFrameHandler()\n\t{\n\t\treturn stompFrameHandler;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/PrivateChatFrameHandler.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.message.MessageType;\nimport io.xeres.common.message.chat.ChatAvatar;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\nimport org.springframework.messaging.simp.stomp.StompHeaders;\n\nimport java.lang.reflect.Type;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\nimport static io.xeres.common.message.MessageHeaders.MESSAGE_TYPE;\n\n/**\n * This handles the incoming private messages from the server to the UI.\n */\npublic class PrivateChatFrameHandler implements StompFrameHandler\n{\n\tprivate final WindowManager windowManager;\n\n\tpublic PrivateChatFrameHandler(WindowManager windowManager)\n\t{\n\t\tthis.windowManager = windowManager;\n\t}\n\n\t/**\n\t * Gets the payload type. It's not possible to use null or new Object(). It has to be a class\n\t * that is serializable by jackson.\n\t *\n\t * @param headers the headers\n\t * @return a type\n\t */\n\t@Override\n\tpublic Type getPayloadType(StompHeaders headers)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\treturn switch (messageType)\n\t\t\t\t{\n\t\t\t\t\tcase CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> ChatMessage.class;\n\t\t\t\t\tcase CHAT_AVATAR -> ChatAvatar.class;\n\t\t\t\t\tcase CHAT_AVAILABILITY -> Availability.class;\n\t\t\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t\t\t};\n\t}\n\n\t@Override\n\tpublic void handleFrame(StompHeaders headers, Object payload)\n\t{\n\t\tvar messageType = MessageType.valueOf(headers.getFirst(MESSAGE_TYPE));\n\t\tPlatform.runLater(() -> {\n\t\t\t\t\tswitch (messageType)\n\t\t\t\t\t{\n\t\t\t\t\t\tcase CHAT_PRIVATE_MESSAGE, CHAT_TYPING_NOTIFICATION -> windowManager.openMessaging(LocationIdentifier.fromString(headers.getFirst(DESTINATION_ID)), (ChatMessage) payload);\n\t\t\t\t\t\tcase CHAT_AVATAR -> windowManager.sendMessaging(headers.getFirst(DESTINATION_ID), (ChatAvatar) payload);\n\t\t\t\t\t\tcase CHAT_AVAILABILITY -> windowManager.sendMessaging(headers.getFirst(DESTINATION_ID), (Availability) payload);\n\t\t\t\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + messageType);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/SessionHandler.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.messaging.simp.stomp.*;\nimport org.springframework.web.socket.WebSocketHttpHeaders;\nimport org.springframework.web.socket.messaging.WebSocketStompClient;\n\nimport java.util.concurrent.CompletableFuture;\n\npublic class SessionHandler extends StompSessionHandlerAdapter\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(SessionHandler.class);\n\n\tpublic interface OnConnected\n\t{\n\t\tvoid afterConnected(StompSession session);\n\t}\n\n\tprivate final WebSocketStompClient stompClient;\n\tprivate final String url;\n\tprivate final WebSocketHttpHeaders httpHeaders;\n\tprivate final OnConnected onConnected;\n\n\tprivate CompletableFuture<StompSession> future;\n\n\n\tSessionHandler(WebSocketStompClient stompClient, String url, WebSocketHttpHeaders httpHeaders, OnConnected onConnected)\n\t{\n\t\tthis.stompClient = stompClient;\n\t\tthis.url = url;\n\t\tthis.httpHeaders = httpHeaders;\n\t\tthis.onConnected = onConnected;\n\t}\n\n\tpublic void connect()\n\t{\n\t\tfuture = stompClient.connectAsync(url, httpHeaders, new StompHeaders(), this);\n\t}\n\n\tpublic CompletableFuture<StompSession> getFuture()\n\t{\n\t\treturn future;\n\t}\n\n\t@Override\n\tpublic void afterConnected(StompSession session, StompHeaders connectedHeaders)\n\t{\n\t\tlog.debug(\"Connected successfully to session {}, headers: {}\", session, connectedHeaders);\n\t\tonConnected.afterConnected(session);\n\t}\n\n\t@Override\n\tpublic void handleException(StompSession session, StompCommand command, StompHeaders headers, byte[] payload, Throwable exception)\n\t{\n\t\tlog.error(\"StompSessionHandler Exception for session {}, command {}, headers {} and payload {}\", session, command, headers, payload, exception);\n\t}\n\n\t@Override\n\tpublic void handleTransportError(StompSession session, Throwable exception)\n\t{\n\t\tif (exception instanceof ConnectionLostException)\n\t\t{\n\t\t\tlog.debug(\"Connection closed: {}\", exception.getMessage());\n\t\t\tPlatform.runLater(() -> UiUtils.showAlertConfirm(I18nUtils.getBundle().getString(\"websocket.disconnected\"), this::connect));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlog.warn(\"StompSessionHandler Transport Exception for session {}\", session, exception);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/message/VoipFrameHandler.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.message;\n\nimport io.xeres.common.message.voip.VoipMessage;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport org.springframework.messaging.simp.stomp.StompFrameHandler;\nimport org.springframework.messaging.simp.stomp.StompHeaders;\n\nimport java.lang.reflect.Type;\n\nimport static io.xeres.common.message.MessageHeaders.DESTINATION_ID;\n\npublic class VoipFrameHandler implements StompFrameHandler\n{\n\tprivate final WindowManager windowManager;\n\n\tpublic VoipFrameHandler(WindowManager windowManager)\n\t{\n\t\tthis.windowManager = windowManager;\n\t}\n\n\t@Override\n\tpublic Type getPayloadType(StompHeaders headers)\n\t{\n\t\treturn VoipMessage.class;\n\t}\n\n\t@Override\n\tpublic void handleFrame(StompHeaders headers, Object payload)\n\t{\n\t\tPlatform.runLater(() -> windowManager.doVoip(headers.getFirst(DESTINATION_ID), (VoipMessage) payload));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/preview/OEmbedResponse.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.preview;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nrecord OEmbedResponse(\n\t\tString type,\n\t\tString version,\n\t\tString title,\n\t\t@JsonProperty(\"author_name\")\n\t\tString authorName,\n\t\t@JsonProperty(\"author_url\")\n\t\tString authorUrl,\n\t\t@JsonProperty(\"provider_name\")\n\t\tString providerName,\n\t\t@JsonProperty(\"provider_url\")\n\t\tString providerUrl,\n\t\t@JsonProperty(\"thumbnail_url\")\n\t\tString thumbnailUrl,\n\t\t@JsonProperty(\"thumbnail_width\")\n\t\tInteger thumbnailWidth,\n\t\t@JsonProperty(\"thumbnail_height\")\n\t\tInteger thumbnailHeight\n)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/preview/PreviewClient.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.preview;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.ui.support.oembed.OEmbedService;\nimport io.xeres.ui.support.util.ClientUtils;\nimport io.xeres.ui.support.util.UriUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.apache.commons.lang3.math.NumberUtils;\nimport org.jsoup.Jsoup;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.io.buffer.DataBuffer;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.client.reactive.ReactorClientHttpConnector;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport org.springframework.web.util.HtmlUtils;\nimport org.springframework.web.util.UriComponentsBuilder;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.netty.http.client.HttpClient;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.stream.Collectors;\n\n@Component\npublic class PreviewClient\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(PreviewClient.class);\n\n\tprivate static final int HEAD_RANGE = 32768;\n\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\tprivate final OEmbedService oembedService;\n\n\tpublic PreviewClient(WebClient.Builder webClientBuilder, OEmbedService oembedService)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t\tthis.oembedService = oembedService;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.defaultHeaders(HttpHeaders::clear) // Do not let remote sites know our credentials\n\t\t\t\t.defaultHeader(HttpHeaders.USER_AGENT, ClientUtils.GENERAL_USER_AGENT)\n\t\t\t\t.clientConnector(new ReactorClientHttpConnector(HttpClient.create().followRedirect(true))) // Follow redirects\n\t\t\t\t.build();\n\t}\n\n\t/**\n\t * Gets information about a URL.\n\t *\n\t * @param url the URL\n\t * @return the information\n\t */\n\tpublic Mono<PreviewResponse> getPreview(String url)\n\t{\n\t\tif (!UriUtils.isSafeEnough(url) || !url.startsWith(\"https://\")) // Only preview https links\n\t\t{\n\t\t\treturn Mono.just(PreviewResponse.EMPTY);\n\t\t}\n\t\tvar oEmbedUrl = oEmbedUrl(url);\n\n\t\tif (StringUtils.isEmpty(oEmbedUrl))\n\t\t{\n\t\t\treturn getOpenGraph(url);\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn getOEmbed(oEmbedUrl, url);\n\t\t}\n\t}\n\n\tpublic Mono<byte[]> getImage(String url)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(url)\n\t\t\t\t.accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(byte[].class);\n\t}\n\n\tprivate String oEmbedUrl(String url)\n\t{\n\t\treturn oembedService.getOembedForUrl(url);\n\t}\n\n\n\t/**\n\t * Gets information about a URL using the <a href=\"https://ogp.me/\">OpenGraph protocol</a>\n\t *\n\t * @param url the URL\n\t * @return the information\n\t */\n\tprivate Mono<PreviewResponse> getOpenGraph(String url)\n\t{\n\t\tlog.debug(\"Using OpenGraph for {}\", url);\n\t\treturn webClient.get()\n\t\t\t\t.uri(url)\n\t\t\t\t.accept(MediaType.TEXT_HTML)\n\t\t\t\t.header(HttpHeaders.RANGE, String.format(\"bytes=%d-%d\", 0, HEAD_RANGE))\n\t\t\t\t.header(HttpHeaders.ACCEPT_ENCODING, \"identity\") // No compression\n\t\t\t\t.header(HttpHeaders.USER_AGENT, masqueradeUserAgent(url))\n\t\t\t\t.exchangeToMono(response -> {\n\t\t\t\t\tif (response.statusCode() != HttpStatus.PARTIAL_CONTENT)\n\t\t\t\t\t{\n\t\t\t\t\t\tlog.debug(\"Server returned full content to our range request, truncating response...\");\n\t\t\t\t\t}\n\t\t\t\t\t// Most servers don't support range requests for dynamic content so we need to truncate.\n\t\t\t\t\treturn response.bodyToFlux(DataBuffer.class)\n\t\t\t\t\t\t\t.collect(() -> new SizeLimitingCollector(HEAD_RANGE),\n\t\t\t\t\t\t\t\t\tSizeLimitingCollector::add)\n\t\t\t\t\t\t\t.map(collector -> new String(collector.getResult(), StandardCharsets.UTF_8));\n\t\t\t\t})\n\t\t\t\t.publishOn(Schedulers.boundedElastic()) // Because we might block to fetch the possible oembed link\n\t\t\t\t.map(s -> toPreviewResponse(s, url));\n\t}\n\n\t/**\n\t * Gets information about a URL using the <a href=\"https://oembed.com/\">oEmbed protocol</a>\n\t *\n\t * @param oembedUrl the URL\n\t * @return the information\n\t */\n\tprivate Mono<PreviewResponse> getOEmbed(String oembedUrl, String url)\n\t{\n\t\tlog.debug(\"Using oEmbed for {}\", url);\n\t\treturn webClient.get()\n\t\t\t\t.uri(_ -> UriComponentsBuilder.fromUriString(oembedUrl)\n\t\t\t\t\t\t.queryParam(\"format\", \"json\")\n\t\t\t\t\t\t.queryParam(\"url\", url)\n\t\t\t\t\t\t.build().toUri())\n\t\t\t\t.accept(MediaType.APPLICATION_JSON) // We don't want the XML variant...\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(OEmbedResponse.class)\n\t\t\t\t.map(PreviewClient::toPreviewResponse);\n\t}\n\n\tprivate PreviewResponse toPreviewResponse(String content, String url)\n\t{\n\t\t// No need to check for <head> and </head>, Jsoup can parse partial tags fine\n\t\tvar document = Jsoup.parse(content, url);\n\t\tvar metaElements = document.select(\"meta\");\n\t\tvar ogs = metaElements.stream()\n\t\t\t\t.filter(element -> element.attr(\"property\").startsWith(\"og:\"))\n\t\t\t\t.collect(Collectors.toMap(element -> element.attr(\"property\"), element -> element.attr(\"content\")));\n\t\tvar previewResponse = new PreviewResponse(\n\t\t\t\togs.getOrDefault(\"og:title\", \"\"),\n\t\t\t\togs.getOrDefault(\"og:description\", \"\"),\n\t\t\t\togs.getOrDefault(\"og:site_name\", \"\"),\n\t\t\t\togs.getOrDefault(\"og:image\", \"\"),\n\t\t\t\tNumberUtils.toInt(ogs.getOrDefault(\"og:image:width\", \"0\")),\n\t\t\t\tNumberUtils.toInt(ogs.getOrDefault(\"og:image:height\", \"0\")));\n\n\t\tif (!previewResponse.hasThumbnail())\n\t\t{\n\t\t\t// No thumbnail? Try to find an oEmbed link in the head\n\t\t\tvar linkElements = document.select(\"link\");\n\t\t\tvar oembedLink = linkElements.stream()\n\t\t\t\t\t.filter(element -> element.attr(\"type\").equals(\"application/json+oembed\") && !element.attr(\"href\").isBlank())\n\t\t\t\t\t.map(element -> element.attr(\"href\"))\n\t\t\t\t\t.findFirst().orElse(null);\n\n\t\t\tif (oembedLink != null && UriUtils.isSafeEnough(oembedLink))\n\t\t\t{\n\t\t\t\treturn getOEmbed(oembedLink, url).block();\n\t\t\t}\n\t\t}\n\t\treturn previewResponse;\n\t}\n\n\tprivate static PreviewResponse toPreviewResponse(OEmbedResponse response)\n\t{\n\t\t// Twitter has not title so let's use the author instead\n\t\tvar titleOrAuthor = StringUtils.isBlank(response.title()) ? response.authorName() : response.title();\n\n\t\treturn new PreviewResponse(\n\t\t\t\tHtmlUtils.htmlUnescape(StringUtils.defaultString(titleOrAuthor)),\n\t\t\t\t\"\",\n\t\t\t\tHtmlUtils.htmlUnescape(StringUtils.defaultString(response.providerName())),\n\t\t\t\tHtmlUtils.htmlUnescape(StringUtils.defaultString(response.thumbnailUrl())),\n\t\t\t\tresponse.thumbnailWidth() != null ? response.thumbnailWidth() : 0,\n\t\t\t\tresponse.thumbnailHeight() != null ? response.thumbnailHeight() : 0\n\t\t);\n\t}\n\n\t/**\n\t * Some sites return different data depending on the user agent.\n\t *\n\t * @param url the url to check\n\t * @return the user agent to return\n\t */\n\tprivate String masqueradeUserAgent(String url)\n\t{\n\t\t// instagram doesn't show opengraph header to non-logged in users in a normal browser\n\t\tif (url.startsWith(\"https://www.instagram.com\") || url.startsWith(\"https://instagram.org\"))\n\t\t{\n\t\t\treturn \"googlebot-mobile\";\n\t\t}\n\t\treturn ClientUtils.GENERAL_USER_AGENT;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/preview/PreviewResponse.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.preview;\n\nimport org.apache.commons.lang3.StringUtils;\n\npublic record PreviewResponse(\n\t\tString title,\n\t\tString description,\n\t\tString site,\n\t\tString thumbnailUrl,\n\t\tint thumbnailWidth,\n\t\tint thumbnailHeight\n)\n{\n\tpublic static final PreviewResponse EMPTY = new PreviewResponse(null, null, null, null, 0, 0);\n\n\tpublic PreviewResponse\n\t{\n\t\ttitle = StringUtils.abbreviate(title, 128);\n\t\tdescription = StringUtils.abbreviate(description, 256);\n\t\tsite = StringUtils.abbreviate(site, 32);\n\t\tthumbnailUrl = StringUtils.truncate(thumbnailUrl, 2048);\n\t}\n\n\tpublic boolean isEmpty()\n\t{\n\t\treturn equals(EMPTY);\n\t}\n\n\tpublic boolean hasInfo()\n\t{\n\t\treturn StringUtils.isNotBlank(title) || StringUtils.isNotBlank(description) || StringUtils.isNotBlank(site) || hasThumbnail();\n\t}\n\n\tpublic boolean hasThumbnail()\n\t{\n\t\treturn StringUtils.isNotBlank(thumbnailUrl);\n\t}\n\n\tpublic boolean hasThumbnailDimensions()\n\t{\n\t\treturn hasThumbnail() && thumbnailWidth > 0 && thumbnailHeight > 0;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/preview/SizeLimitingCollector.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.preview;\n\nimport org.springframework.core.io.buffer.DataBuffer;\nimport org.springframework.core.io.buffer.DataBufferUtils;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * A DataBuffer collector that can avoid fetching the whole HTML file.\n */\npublic class SizeLimitingCollector\n{\n\tprivate final List<byte[]> chunks = new ArrayList<>();\n\tprivate long totalBytes;\n\tprivate final long maxBytes;\n\tprivate boolean limitReached;\n\n\tpublic SizeLimitingCollector(long maxBytes)\n\t{\n\t\tthis.maxBytes = maxBytes;\n\t}\n\n\tpublic void add(DataBuffer buffer)\n\t{\n\t\tif (limitReached)\n\t\t{\n\t\t\tDataBufferUtils.release(buffer);\n\t\t\treturn;\n\t\t}\n\n\t\tint readableBytes = buffer.readableByteCount();\n\n\t\tif (totalBytes + readableBytes <= maxBytes)\n\t\t{\n\t\t\t// We can take the whole buffer\n\t\t\tvar bytes = new byte[readableBytes];\n\t\t\tbuffer.read(bytes);\n\t\t\tchunks.add(bytes);\n\t\t\ttotalBytes += readableBytes;\n\t\t\tDataBufferUtils.release(buffer);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Partial buffer\n\t\t\tint bytesToTake = (int) (maxBytes - totalBytes);\n\t\t\tif (bytesToTake > 0)\n\t\t\t{\n\t\t\t\tvar bytes = new byte[bytesToTake];\n\t\t\t\tbuffer.read(bytes);\n\t\t\t\tchunks.add(bytes);\n\t\t\t\ttotalBytes += bytesToTake;\n\t\t\t}\n\t\t\tDataBufferUtils.release(buffer);\n\t\t\tlimitReached = true;\n\t\t}\n\t}\n\n\tpublic byte[] getResult()\n\t{\n\t\t// Combine all chunks into one byte array\n\t\tvar result = new byte[(int) totalBytes];\n\t\tvar offset = 0;\n\t\tfor (byte[] chunk : chunks)\n\t\t{\n\t\t\tSystem.arraycopy(chunk, 0, result, offset, chunk.length);\n\t\t\toffset += chunk.length;\n\t\t}\n\t\treturn result;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/update/ReleaseAsset.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.update;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\npublic record ReleaseAsset(\n\t\tString name,\n\t\t@JsonProperty(\"browser_download_url\") String url\n)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/update/ReleaseResponse.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.update;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport java.util.List;\n\npublic record ReleaseResponse(\n\t\t@JsonProperty(\"tag_name\") String tagName,\n\t\tList<ReleaseAsset> assets\n)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/update/UpdateClient.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.update;\n\nimport io.xeres.common.events.StartupEvent;\nimport io.xeres.ui.support.util.ClientUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.io.buffer.DataBuffer;\nimport org.springframework.core.io.buffer.DataBufferUtils;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.client.reactive.ReactorClientHttpConnector;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.netty.http.client.HttpClient;\n\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.function.Consumer;\n\n@Component\npublic class UpdateClient\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UpdateClient.class);\n\n\tprivate final WebClient.Builder webClientBuilder;\n\n\tprivate WebClient webClient;\n\n\tpublic UpdateClient(WebClient.Builder webClientBuilder)\n\t{\n\t\tthis.webClientBuilder = webClientBuilder;\n\t}\n\n\t@EventListener\n\tpublic void init(@SuppressWarnings(\"unused\") StartupEvent event)\n\t{\n\t\twebClient = webClientBuilder.clone()\n\t\t\t\t.baseUrl(\"https://api.github.com/repos/zapek/Xeres\")\n\t\t\t\t.defaultHeaders(HttpHeaders::clear) // Do not let GitHub know our remote user/password\n\t\t\t\t.defaultHeader(HttpHeaders.USER_AGENT, ClientUtils.GENERAL_USER_AGENT)\n\t\t\t\t.clientConnector(new ReactorClientHttpConnector(HttpClient.create().followRedirect(true))) // This is needed if we want to follow redirects, which GitHub uses\n\t\t\t\t.build();\n\t}\n\n\tpublic Mono<ReleaseResponse> getLatestVersion()\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(\"/releases/latest\")\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(ReleaseResponse.class);\n\t}\n\n\tpublic Mono<Void> downloadFile(String url, Path destination)\n\t{\n\t\tvar dataBufferFlux = webClient.get()\n\t\t\t\t.uri(url)\n\t\t\t\t.accept(MediaType.APPLICATION_OCTET_STREAM)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToFlux(DataBuffer.class);\n\n\t\tlog.debug(\"Downloading file {} to {}\", url, destination);\n\n\t\treturn DataBufferUtils.write(dataBufferFlux, destination, StandardOpenOption.WRITE);\n\t}\n\n\tpublic Mono<byte[]> downloadFile(String url)\n\t{\n\t\treturn webClient.get()\n\t\t\t\t.uri(url)\n\t\t\t\t.accept(MediaType.APPLICATION_OCTET_STREAM)\n\t\t\t\t.retrieve()\n\t\t\t\t.bodyToMono(byte[].class);\n\t}\n\n\tpublic Flux<DataBuffer> downloadFileWithProgress(String url, Path destination, Consumer<UpdateProgress> progress)\n\t{\n\t\tvar updateProgress = new UpdateProgress(destination, progress);\n\n\t\tvar dataBufferFlux = webClient.get()\n\t\t\t\t.uri(url)\n\t\t\t\t.accept(MediaType.APPLICATION_OCTET_STREAM)\n\t\t\t\t.exchangeToFlux(response -> {\n\t\t\t\t\tlong contentLength = response.headers().contentLength().orElse(-1);\n\t\t\t\t\tupdateProgress.setContentLength(contentLength);\n\t\t\t\t\treturn response.bodyToFlux(DataBuffer.class);\n\t\t\t\t});\n\n\t\treturn DataBufferUtils.write(dataBufferFlux, updateProgress.getOutputStream())\n\t\t\t\t.doOnNext(DataBufferUtils::release);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/client/update/UpdateProgress.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client.update;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.util.function.Consumer;\n\n/**\n * OutputStream that reports progress.\n */\npublic class UpdateProgress\n{\n\tpublic static final int UPDATE_DELAY = 33; // 30 Hz\n\n\tprivate long contentLength = -1;\n\tprivate long downloaded;\n\tprivate final OutputStream outputStream;\n\n\tpublic UpdateProgress(Path destination, Consumer<UpdateProgress> callback)\n\t{\n\t\tFileOutputStream out;\n\n\t\ttry\n\t\t{\n\t\t\tout = new FileOutputStream(destination.toFile());\n\t\t}\n\t\tcatch (FileNotFoundException e)\n\t\t{\n\t\t\tthrow new RuntimeException(\"File not found: \" + destination.toAbsolutePath(), e);\n\t\t}\n\n\t\toutputStream = new FilterOutputStream(out)\n\t\t{\n\t\t\tprivate long lastTime;\n\n\t\t\t@Override\n\t\t\tpublic void write(byte[] b, int off, int len) throws IOException\n\t\t\t{\n\t\t\t\tout.write(b, off, len);\n\t\t\t\tdownloaded += len;\n\t\t\t\tupdateStatus();\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic void write(int b) throws IOException\n\t\t\t{\n\t\t\t\tout.write(b);\n\t\t\t\tdownloaded++;\n\t\t\t\tupdateStatus();\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic void write(byte[] b) throws IOException\n\t\t\t{\n\t\t\t\tout.write(b);\n\t\t\t\tdownloaded += b.length;\n\t\t\t\tupdateStatus();\n\t\t\t}\n\n\t\t\tprivate void updateStatus() throws IOException\n\t\t\t{\n\t\t\t\tvar now = System.currentTimeMillis();\n\t\t\t\tif (now - lastTime > UPDATE_DELAY || downloaded == contentLength)\n\t\t\t\t{\n\t\t\t\t\tif (downloaded == contentLength)\n\t\t\t\t\t{\n\t\t\t\t\t\tclose();\n\t\t\t\t\t}\n\t\t\t\t\tcallback.accept(UpdateProgress.this);\n\t\t\t\t\tlastTime = now;\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t}\n\n\tpublic void setContentLength(long contentLength)\n\t{\n\t\tthis.contentLength = contentLength;\n\t}\n\n\tpublic OutputStream getOutputStream()\n\t{\n\t\treturn outputStream;\n\t}\n\n\tpublic double getProgress()\n\t{\n\t\tif (contentLength == -1)\n\t\t{\n\t\t\treturn 0;\n\t\t}\n\t\treturn downloaded / (double) contentLength;\n\t}\n\n\tpublic long getDownloaded()\n\t{\n\t\treturn downloaded;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/configuration/I18nConfiguration.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.configuration;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\nimport java.util.ResourceBundle;\n\n@Configuration\npublic class I18nConfiguration\n{\n\t@Bean\n\tpublic ResourceBundle bundle()\n\t{\n\t\treturn I18nUtils.getBundle();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/configuration/WebClientConfiguration.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.configuration;\n\nimport io.netty.handler.ssl.SslContextBuilder;\nimport io.netty.handler.ssl.util.InsecureTrustManagerFactory;\nimport io.xeres.common.properties.StartupProperties;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.http.client.reactive.ReactorClientHttpConnector;\nimport org.springframework.http.codec.json.JacksonJsonDecoder;\nimport org.springframework.http.codec.json.JacksonJsonEncoder;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.netty.http.client.HttpClient;\nimport tools.jackson.databind.json.JsonMapper;\n\nimport javax.net.ssl.SSLException;\n\n/**\n * This configuration overrides the default one of Spring Boot by making sure we only use\n * a global webclient. Spring Boot has one that is customized then cloned so that it can only\n * be modified globally once and from a configuration.\n */\n@Configuration\npublic class WebClientConfiguration\n{\n\tpublic static final int MAX_IN_MEMORY = 300 * 1024;\n\n\tprivate final JsonMapper jsonMapper;\n\n\tpublic WebClientConfiguration(JsonMapper jsonMapper)\n\t{\n\t\tthis.jsonMapper = jsonMapper;\n\t}\n\n\t@Bean\n\tpublic WebClient.Builder webClientBuilder() throws SSLException\n\t{\n\t\tvar webClientBuilder = createWebClientBuilder();\n\t\t// Allow bigger message sizes (default is 256 KB). Not used yet but potentially\n\t\t// a private message can be around 300 KB.\n\t\twebClientBuilder.codecs(clientCodecConfigurer -> {\n\t\t\tvar defaultCodecs = clientCodecConfigurer.defaultCodecs();\n\t\t\tdefaultCodecs.maxInMemorySize(MAX_IN_MEMORY);\n\t\t\tdefaultCodecs.jacksonJsonDecoder(new JacksonJsonDecoder(jsonMapper));\n\t\t\tdefaultCodecs.jacksonJsonEncoder(new JacksonJsonEncoder(jsonMapper));\n\t\t});\n\t\treturn webClientBuilder;\n\t}\n\n\tprivate WebClient.Builder createWebClientBuilder() throws SSLException\n\t{\n\t\tvar useHttps = StartupProperties.getBoolean(StartupProperties.Property.HTTPS, true);\n\n\t\tif (useHttps)\n\t\t{\n\t\t\tvar sslContext = SslContextBuilder.forClient()\n\t\t\t\t\t.trustManager(InsecureTrustManagerFactory.INSTANCE)\n\t\t\t\t\t.build();\n\t\t\tvar httpClient = HttpClient.create().secure(t -> t.sslContext(sslContext));\n\n\t\t\treturn WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient));\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn WebClient.builder();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/Controller.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller;\n\nimport java.io.IOException;\n\n/**\n * Use this interface when building a controller. If you need a Window, use WindowController instead.\n */\npublic interface Controller\n{\n\tvoid initialize() throws IOException; // IOException is often thrown in initialize() because of FXML loading, just remove it from the implementation if you don't use it\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/MainWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller;\n\nimport atlantafx.base.controls.Notification;\nimport atlantafx.base.theme.Styles;\nimport atlantafx.base.util.Animations;\nimport io.xeres.common.mui.MUI;\nimport io.xeres.common.rest.notification.status.DhtInfo;\nimport io.xeres.common.rest.notification.status.NatStatus;\nimport io.xeres.common.rsid.Type;\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.controller.chat.ChatViewController;\nimport io.xeres.ui.controller.file.FileMainController;\nimport io.xeres.ui.custom.DelayedAction;\nimport io.xeres.ui.custom.ReadOnlyTextField;\nimport io.xeres.ui.custom.led.LedControl;\nimport io.xeres.ui.custom.led.LedStatus;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.event.UnreadEvent;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.tray.TrayService;\nimport io.xeres.ui.support.updater.UpdateService;\nimport io.xeres.ui.support.uri.*;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport jakarta.annotation.Nullable;\nimport javafx.animation.*;\nimport javafx.application.HostServices;\nimport javafx.application.Platform;\nimport javafx.event.EventHandler;\nimport javafx.fxml.FXML;\nimport javafx.geometry.Insets;\nimport javafx.geometry.Pos;\nimport javafx.scene.control.*;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyCodeCombination;\nimport javafx.scene.input.KeyCombination;\nimport javafx.scene.input.KeyEvent;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.layout.Region;\nimport javafx.scene.layout.StackPane;\nimport javafx.stage.FileChooser;\nimport javafx.stage.FileChooser.ExtensionFilter;\nimport javafx.stage.Stage;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignI;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.core.env.Environment;\nimport org.springframework.core.env.Profiles;\nimport org.springframework.core.io.buffer.DataBufferUtils;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.text.MessageFormat;\nimport java.time.Duration;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\n\n@Component\n@FxmlView(value = \"/view/main.fxml\")\npublic class MainWindowController implements WindowController\n{\n\tprivate static final String XERES_DOCS_URL = \"https://xeres.io/docs\";\n\tprivate static final String XERES_BUGS_URL = \"https://github.com/zapek/Xeres/issues/new/choose\";\n\n\tprivate static final KeyCombination SHELL_SHORTCUT = new KeyCodeCombination(\n\t\t\tKeyCode.F12\n\t);\n\n\tprivate static final KeyCombination HELP_SHORTCUT = new KeyCodeCombination(\n\t\t\tKeyCode.F1, KeyCombination.SHORTCUT_DOWN // This is the online help, F1 alone is mapped to the built-in documentation\n\t);\n\n\tprivate EventHandler<KeyEvent> keyEventHandler;\n\n\t@FXML\n\tprivate StackPane stackPane;\n\n\t@FXML\n\tprivate TabPane tabPane;\n\n\t@FXML\n\tprivate Tab homeTab;\n\n\t@FXML\n\tprivate Tab chatTab;\n\n\t@FXML\n\tprivate Tab contactTab;\n\n\t@FXML\n\tprivate Tab forumTab;\n\n\t@FXML\n\tprivate Tab channelTab;\n\n\t@FXML\n\tprivate Tab boardTab;\n\n\t@FXML\n\tprivate Tab fileTab;\n\n\t@FXML\n\tprivate ImageView logo;\n\n\t@FXML\n\tprivate Label titleLabel;\n\n\t@FXML\n\tprivate Label shareId;\n\n\t@FXML\n\tprivate Label slogan;\n\n\t@FXML\n\tprivate MenuItem addPeer;\n\n\t@FXML\n\tprivate MenuItem launchWebInterface;\n\n\t@FXML\n\tprivate MenuItem launchSwagger;\n\n\t@FXML\n\tprivate MenuItem exitApplication;\n\n\t@FXML\n\tprivate MenuItem showDocumentation;\n\n\t@FXML\n\tprivate MenuItem reportBug;\n\n\t@FXML\n\tprivate MenuItem showAboutWindow;\n\n\t@FXML\n\tprivate MenuItem showBroadcastWindow;\n\n\t@FXML\n\tprivate MenuItem exportBackup;\n\n\t@FXML\n\tprivate MenuItem importFriends;\n\n\t@FXML\n\tprivate MenuItem statistics;\n\n\t@FXML\n\tprivate MenuItem showSettingsWindow;\n\n\t@FXML\n\tprivate MenuItem showSharesWindow;\n\n\t@FXML\n\tprivate Menu debug;\n\n\t@FXML\n\tprivate SeparatorMenuItem debugSeparator;\n\n\t@FXML\n\tprivate MenuItem runGc;\n\n\t@FXML\n\tprivate MenuItem h2Console;\n\n\t@FXML\n\tprivate MenuItem openShell;\n\n\t@FXML\n\tprivate MenuItem showErrorException;\n\n\t@FXML\n\tprivate MenuItem showError;\n\n\t@FXML\n\tprivate MenuItem showThemeExample;\n\n\t@FXML\n\tprivate MenuItem versionCheck;\n\n\t@FXML\n\tprivate ReadOnlyTextField shortId;\n\n\t@FXML\n\tprivate Button copyShortIdButton;\n\n\t@FXML\n\tprivate Button showQrCodeButton;\n\n\t@FXML\n\tprivate Button addFriendButton;\n\n\t@FXML\n\tprivate Button webHelpButton;\n\n\t@FXML\n\tprivate Label numberOfConnections;\n\n\t@FXML\n\tprivate LedControl natStatus;\n\n\t@FXML\n\tprivate LedControl dhtStatus;\n\n\t@FXML\n\tprivate HBox hashingStatus;\n\n\t@FXML\n\tprivate Label hashingName;\n\n\t@FXML\n\tprivate FileMainController fileMainController;\n\n\tprivate final ChatViewController chatViewController;\n\n\tprivate final LocationClient locationClient;\n\tprivate final TrayService trayService;\n\tprivate final WindowManager windowManager;\n\tprivate final Environment environment;\n\tprivate final ConfigClient configClient;\n\tprivate final NotificationClient notificationClient;\n\tprivate final HostServices hostServices;\n\tprivate final UpdateService updateService;\n\tprivate final ResourceBundle bundle;\n\n\tprivate int currentUsers;\n\tprivate int totalUsers;\n\tprivate Disposable statusNotificationDisposable;\n\tprivate Disposable fileNotificationDisposable;\n\n\tprivate DelayedAction hashingDelayedDisplayAction;\n\n\tpublic MainWindowController(ChatViewController chatViewController, LocationClient locationClient, TrayService trayService, WindowManager windowManager, Environment environment, ConfigClient configClient, NotificationClient notificationClient, @SuppressWarnings(\"SpringJavaInjectionPointsAutowiringInspection\") @Nullable HostServices hostServices, @Lazy UpdateService updateService, ResourceBundle bundle)\n\t{\n\t\tthis.chatViewController = chatViewController;\n\t\tthis.locationClient = locationClient;\n\t\tthis.trayService = trayService;\n\t\tthis.windowManager = windowManager;\n\t\tthis.environment = environment;\n\t\tthis.configClient = configClient;\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.hostServices = hostServices;\n\t\tthis.updateService = updateService;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\taddPeer.setOnAction(_ -> windowManager.openAddPeer());\n\t\taddFriendButton.setOnAction(_ -> windowManager.openAddPeer());\n\n\t\tcopyShortIdButton.setOnAction(_ -> copyOwnId());\n\n\t\tshowQrCodeButton.setOnAction(_ -> showQrCode());\n\n\t\tlaunchWebInterface.setOnAction(_ -> openUrl(RemoteUtils.getControlUrl()));\n\t\tlaunchSwagger.setOnAction(_ -> openUrl(RemoteUtils.getControlUrl() + \"/swagger-ui/index.html\"));\n\n\t\tshowDocumentation.setOnAction(_ -> windowManager.openDocumentation(true));\n\t\twebHelpButton.setOnAction(_ -> openUrl(XERES_DOCS_URL));\n\n\t\treportBug.setOnAction(_ -> openUrl(XERES_BUGS_URL));\n\n\t\tshowAboutWindow.setOnAction(_ -> windowManager.openAbout());\n\n\t\tshowBroadcastWindow.setOnAction(_ -> windowManager.openBroadcast());\n\n\t\tshowSettingsWindow.setOnAction(_ -> windowManager.openSettings());\n\n\t\tshowSharesWindow.setOnAction(_ -> windowManager.openShare());\n\n\t\texportBackup.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"main.export-profile\"));\n\t\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\t\tfileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString(\"file-requester.xml\"), \"*.xml\"));\n\t\t\tfileChooser.setInitialFileName(\"xeres_backup.xml\");\n\t\t\tvar selectedFile = fileChooser.showSaveDialog(getWindow(event));\n\t\t\tif (selectedFile != null)\n\t\t\t{\n\t\t\t\tDataBufferUtils.write(configClient.getBackup(), selectedFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING).subscribe();\n\t\t\t}\n\t\t});\n\n\t\timportFriends.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"main.import-friends\"));\n\t\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\t\tfileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString(\"file-requester.xml\"), \"*.xml\"));\n\t\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\t\tif (selectedFile != null && selectedFile.canRead())\n\t\t\t{\n\t\t\t\tconfigClient.sendRsFriends(selectedFile)\n\t\t\t\t\t\t.doOnSuccess(response -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert response != null;\n\t\t\t\t\t\t\tif (response.errors() > 0)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tUiUtils.showAlert(Alert.AlertType.WARNING, MessageFormat.format(bundle.getString(\"main.friends-import-errors\"), response.success(), response.errors()));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tUiUtils.showAlert(Alert.AlertType.INFORMATION, MessageFormat.format(bundle.getString(\"main.friends-import-successful\"), response.success()));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tstatistics.setOnAction(_ -> windowManager.openStatistics());\n\n\t\tif (environment.acceptsProfiles(Profiles.of(\"dev\")))\n\t\t{\n\t\t\tdebugSeparator.setVisible(true);\n\t\t\tdebug.setVisible(true);\n\t\t\trunGc.setOnAction(_ -> System.gc());\n\t\t\th2Console.setOnAction(_ -> openUrl(RemoteUtils.getControlUrl() + \"/h2-console\"));\n\t\t\topenShell.setOnAction(_ -> MUI.openShell());\n\t\t\tshowThemeExample.setOnAction(_ -> windowManager.openThemeExample());\n\t\t\tshowErrorException.setOnAction(_ -> UiUtils.webAlertError(new IllegalArgumentException(\"Dummy error\")));\n\t\t\tshowError.setOnAction(_ -> UiUtils.showAlert(Alert.AlertType.ERROR, \"This is some error blabla\"));\n\t\t}\n\n\t\tversionCheck.setOnAction(_ -> updateService.checkForUpdate());\n\n\t\texitApplication.setOnAction(_ -> trayService.exitApplication());\n\n\t\tsetupNotifications();\n\n\t\ttrayService.addSystemTray(windowManager.getFullTitle());\n\n\t\tlocationClient.getRSId(OWN_LOCATION_ID, Type.SHORT_INVITE)\n\t\t\t\t.doOnSuccess(rsIdResponse -> Platform.runLater(() -> {\n\t\t\t\t\tassert rsIdResponse != null;\n\t\t\t\t\tshortId.setText(rsIdResponse.rsId());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tsetupAnimations();\n\n\t\tupdateService.startBackgroundChecksIfEnabled();\n\n\t\tkeyEventHandler = event -> {\n\t\t\tif (SHELL_SHORTCUT.match(event))\n\t\t\t{\n\t\t\t\tMUI.openShell();\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t\telse if (HELP_SHORTCUT.match(event))\n\t\t\t{\n\t\t\t\twebHelpButton.fire();\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t};\n\n\t\ttabPane.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> {\n\t\t\tif (chatTab.equals(newValue))\n\t\t\t{\n\t\t\t\taddOrRemoveTabHighlight(chatTab, false);\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic void onShowing()\n\t{\n\t\tfileMainController.resume();\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\twindowManager.setRootWindow(getWindow(titleLabel));\n\t\tchatViewController.jumpToBottom();\n\n\t\tgetWindow(titleLabel).addEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler);\n\n\t\tif (!trayService.hasSystemTray())\n\t\t{\n\t\t\tUiUtils.getWindow(titleLabel).setOnCloseRequest(event -> {\n\t\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"main.exit.confirm\"), () -> UiUtils.getWindow(titleLabel).hide());\n\t\t\t\tevent.consume();\n\t\t\t});\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onHiding()\n\t{\n\t\tfileMainController.suspend();\n\n\t\tgetWindow(titleLabel).removeEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler);\n\t}\n\n\t@Override\n\tpublic void onHidden()\n\t{\n\t\tif (!trayService.hasSystemTray())\n\t\t{\n\t\t\ttrayService.exitApplication();\n\t\t}\n\t}\n\n\tprivate void copyOwnId()\n\t{\n\t\tvar rsIdResponse = locationClient.getRSId(OWN_LOCATION_ID, Type.ANY);\n\t\trsIdResponse.subscribe(reply -> Platform.runLater(() -> ClipboardUtils.copyTextToClipboard(reply.rsId())));\n\t}\n\n\tprivate void showQrCode()\n\t{\n\t\tvar rsIdResponse = locationClient.getRSId(OWN_LOCATION_ID, Type.ANY);\n\t\trsIdResponse.subscribe(reply -> Platform.runLater(() -> windowManager.openQrCode(reply)));\n\t}\n\n\tpublic void showUpdate(String message, String tagName, Runnable downloadAction)\n\t{\n\t\tvar msg = new Notification(message, new FontIcon(MaterialDesignI.INFORMATION));\n\t\tmsg.getStyleClass().addAll(Styles.ACCENT, Styles.ELEVATED_2);\n\t\tmsg.setPrefHeight(Region.USE_PREF_SIZE);\n\t\tmsg.setMaxHeight(Region.USE_PREF_SIZE);\n\n\t\tvar downloadButton = new Button(bundle.getString(\"download\"));\n\t\tdownloadButton.setDefaultButton(true);\n\t\tdownloadButton.setOnAction(_ -> downloadAction.run());\n\t\tvar skipButton = new Button(bundle.getString(\"skip\"));\n\t\tskipButton.setOnAction(_ -> updateService.skipUpdate(tagName));\n\t\tmsg.setPrimaryActions(downloadButton, skipButton);\n\n\t\tStackPane.setAlignment(msg, Pos.TOP_RIGHT);\n\t\tStackPane.setMargin(msg, new Insets(0, 10, 10, 0));\n\t\tmsg.setOnClose(_ -> {\n\t\t\tvar out = Animations.slideOutUp(msg, javafx.util.Duration.millis(250));\n\t\t\tout.setOnFinished(_ -> stackPane.getChildren().remove(msg));\n\t\t\tout.playFromStart();\n\t\t});\n\n\t\tvar in = Animations.slideInDown(msg, javafx.util.Duration.millis(250));\n\t\tstackPane.getChildren().add(msg);\n\t\tin.playFromStart();\n\n\t\t// If the window is iconified, un-iconify it\n\t\t((Stage) UiUtils.getWindow(stackPane)).show();\n\t}\n\n\tprivate void setupNotifications()\n\t{\n\t\t// Apparently the LED is not happy if we don't turn it on first here.\n\t\tnatStatus.setState(true);\n\t\tdhtStatus.setState(true);\n\n\t\tstatusNotificationDisposable = notificationClient.getStatusNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tif (sse.data() != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tsetUserCount(sse.data().currentUsers(), sse.data().totalUsers());\n\t\t\t\t\t\tsetNatStatus(sse.data().natStatus());\n\t\t\t\t\t\tsetDhtInfo(sse.data().dhtInfo());\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tfileNotificationDisposable = notificationClient.getFileNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tif (sse.data() != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tswitch (sse.data().action())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcase START_SCANNING ->\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvar defaultText = MessageFormat.format(bundle.getString(\"main.scanning\"), sse.data().shareName());\n\t\t\t\t\t\t\t\thashingStatus.setVisible(true);\n\t\t\t\t\t\t\t\thashingDelayedDisplayAction = new DelayedAction(() -> hashingName.setText(defaultText), () -> hashingName.setText(null), Duration.ofMillis(2000));\n\t\t\t\t\t\t\t\thashingDelayedDisplayAction.run();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcase START_HASHING ->\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tif (hashingDelayedDisplayAction == null) // Can happen when scanning temporary files\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\thashingDelayedDisplayAction = new DelayedAction(null, () -> hashingName.setText(null), Duration.ofMillis(2000));\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\thashingDelayedDisplayAction.abort();\n\t\t\t\t\t\t\t\thashingName.setText(MessageFormat.format(bundle.getString(\"main.hashing\"), Path.of(sse.data().scannedFile()).getFileName()));\n\t\t\t\t\t\t\t\tTooltipUtils.install(hashingStatus, MessageFormat.format(bundle.getString(\"main.scanning.tip\"), sse.data().shareName(), sse.data().scannedFile()));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcase STOP_HASHING ->\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tTooltipUtils.uninstall(hashingStatus);\n\t\t\t\t\t\t\t\thashingDelayedDisplayAction.run();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcase STOP_SCANNING ->\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tif (hashingDelayedDisplayAction == null) // Can happen when connecting remotely\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\thashingDelayedDisplayAction.abort();\n\t\t\t\t\t\t\t\thashingStatus.setVisible(false);\n\t\t\t\t\t\t\t\thashingDelayedDisplayAction = null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcase NONE ->\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Nothing to do\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void setUserCount(Integer newCurrentUsers, Integer newTotalUsers)\n\t{\n\t\tif (newCurrentUsers != null)\n\t\t{\n\t\t\tcurrentUsers = newCurrentUsers;\n\t\t}\n\t\tif (newTotalUsers != null)\n\t\t{\n\t\t\ttotalUsers = newTotalUsers;\n\t\t}\n\n\t\tnumberOfConnections.setText(currentUsers + \"/\" + totalUsers);\n\t}\n\n\tprivate void setNatStatus(NatStatus newNatStatus)\n\t{\n\t\tif (newNatStatus != null)\n\t\t{\n\t\t\tswitch (newNatStatus)\n\t\t\t{\n\t\t\t\tcase UNKNOWN ->\n\t\t\t\t{\n\t\t\t\t\tTooltipUtils.install(natStatus, bundle.getString(\"main.status.nat.unknown\"));\n\t\t\t\t\tnatStatus.setStatus(LedStatus.WARNING);\n\t\t\t\t}\n\t\t\t\tcase FIREWALLED ->\n\t\t\t\t{\n\t\t\t\t\tTooltipUtils.install(natStatus, bundle.getString(\"main.status.nat.firewalled\"));\n\t\t\t\t\tnatStatus.setStatus(LedStatus.ERROR);\n\t\t\t\t}\n\t\t\t\tcase UPNP ->\n\t\t\t\t{\n\t\t\t\t\tTooltipUtils.install(natStatus, bundle.getString(\"main.status.nat.upnp\"));\n\t\t\t\t\tnatStatus.setStatus(LedStatus.OK);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void setDhtInfo(DhtInfo newDhtInfo)\n\t{\n\t\tif (newDhtInfo != null)\n\t\t{\n\t\t\tswitch (newDhtInfo.dhtStatus())\n\t\t\t{\n\t\t\t\tcase OFF ->\n\t\t\t\t{\n\t\t\t\t\tdhtStatus.setState(false);\n\t\t\t\t\tTooltipUtils.install(dhtStatus, bundle.getString(\"main.status.dht.disabled\"));\n\t\t\t\t}\n\t\t\t\tcase INITIALIZING ->\n\t\t\t\t{\n\t\t\t\t\tdhtStatus.setState(true);\n\t\t\t\t\tdhtStatus.setStatus(LedStatus.WARNING);\n\t\t\t\t\tTooltipUtils.install(dhtStatus, bundle.getString(\"main.status.dht.initializing\"));\n\t\t\t\t}\n\t\t\t\tcase RUNNING ->\n\t\t\t\t{\n\t\t\t\t\tdhtStatus.setState(true);\n\t\t\t\t\tdhtStatus.setStatus(LedStatus.OK);\n\t\t\t\t\tif (newDhtInfo.numPeers() == 0)\n\t\t\t\t\t{\n\t\t\t\t\t\tTooltipUtils.install(dhtStatus, bundle.getString(\"main.status.dht.running\"));\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tTooltipUtils.install(dhtStatus,\n\t\t\t\t\t\t\t\tMessageFormat.format(bundle.getString(\"main.status.dht.stats\"),\n\t\t\t\t\t\t\t\t\t\tnewDhtInfo.numPeers(),\n\t\t\t\t\t\t\t\t\t\tnewDhtInfo.receivedPackets(),\n\t\t\t\t\t\t\t\t\t\tByteUnitUtils.fromBytes(newDhtInfo.receivedBytes()),\n\t\t\t\t\t\t\t\t\t\tnewDhtInfo.sentPackets(),\n\t\t\t\t\t\t\t\t\t\tByteUnitUtils.fromBytes(newDhtInfo.sentBytes()),\n\t\t\t\t\t\t\t\t\t\tnewDhtInfo.keyCount(),\n\t\t\t\t\t\t\t\t\t\tnewDhtInfo.itemCount()));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvents(OpenUriEvent event)\n\t{\n\t\tswitch (event.uri())\n\t\t{\n\t\t\tcase ChatRoomUri _ -> tabPane.getSelectionModel().select(chatTab);\n\t\t\tcase ForumUri _ -> tabPane.getSelectionModel().select(forumTab);\n\t\t\tcase BoardUri _ -> tabPane.getSelectionModel().select(boardTab);\n\t\t\tcase ChannelUri _ -> tabPane.getSelectionModel().select(channelTab);\n\t\t\tcase SearchUri _ -> tabPane.getSelectionModel().select(fileTab);\n\t\t\tcase IdentityUri _, ProfileUri _ -> tabPane.getSelectionModel().select(contactTab);\n\t\t\tdefault ->\n\t\t\t{\n\t\t\t\t// Nothing to do\n\t\t\t}\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void handleUnreadEvents(UnreadEvent event)\n\t{\n\t\tswitch (event.element())\n\t\t{\n\t\t\tcase CHAT_ROOM ->\n\t\t\t{\n\t\t\t\tif (!tabPane.getSelectionModel().getSelectedItem().equals(chatTab))\n\t\t\t\t{\n\t\t\t\t\taddOrRemoveTabHighlight(chatTab, event.unread());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase FORUM -> addOrRemoveTabHighlight(forumTab, event.unread());\n\t\t\tcase FILE -> addOrRemoveTabHighlight(fileTab, event.unread());\n\t\t\tcase BOARD -> addOrRemoveTabHighlight(boardTab, event.unread());\n\t\t\tcase CHANNEL -> addOrRemoveTabHighlight(channelTab, event.unread());\n\t\t}\n\t}\n\n\tprivate void addOrRemoveTabHighlight(Tab tab, boolean add)\n\t{\n\t\tvar styleClass = tab.getStyleClass();\n\t\tif (add)\n\t\t{\n\t\t\tif (!styleClass.contains(\"tab-bold\"))\n\t\t\t{\n\t\t\t\tstyleClass.add(\"tab-bold\");\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tstyleClass.remove(\"tab-bold\");\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (statusNotificationDisposable != null && !statusNotificationDisposable.isDisposed())\n\t\t{\n\t\t\tstatusNotificationDisposable.dispose();\n\t\t}\n\n\t\tif (fileNotificationDisposable != null && !fileNotificationDisposable.isDisposed())\n\t\t{\n\t\t\tfileNotificationDisposable.dispose();\n\t\t}\n\t}\n\n\tprivate void setupAnimations()\n\t{\n\t\tvar rotateTransition = new RotateTransition(javafx.util.Duration.millis(2000), logo);\n\t\trotateTransition.setByAngle(360);\n\t\trotateTransition.setCycleCount(Animation.INDEFINITE);\n\t\trotateTransition.setInterpolator(Interpolator.LINEAR);\n\n\t\tvar scaleTransition = new ScaleTransition(javafx.util.Duration.millis(200), titleLabel);\n\t\tscaleTransition.setByX(0.2);\n\t\tscaleTransition.setByY(0.2);\n\t\tscaleTransition.setAutoReverse(true);\n\t\tscaleTransition.setCycleCount(Animation.INDEFINITE);\n\t\tscaleTransition.setInterpolator(Interpolator.EASE_BOTH);\n\n\t\tvar fadeTransition = new FadeTransition(javafx.util.Duration.millis(100), slogan);\n\t\tfadeTransition.setByValue(-1.0);\n\t\tfadeTransition.setAutoReverse(true);\n\t\tfadeTransition.setCycleCount(Animation.INDEFINITE);\n\t\tfadeTransition.setInterpolator(Interpolator.EASE_BOTH);\n\n\t\tvar translateTransitionLeft = new TranslateTransition(javafx.util.Duration.millis(300));\n\t\ttranslateTransitionLeft.setFromX(0.0);\n\t\ttranslateTransitionLeft.setToX(-80.0);\n\t\ttranslateTransitionLeft.setAutoReverse(true);\n\t\ttranslateTransitionLeft.setCycleCount(2);\n\t\ttranslateTransitionLeft.setInterpolator(Interpolator.LINEAR);\n\n\t\tvar translateTransitionRight = new TranslateTransition(javafx.util.Duration.millis(300));\n\t\ttranslateTransitionRight.setFromX(0.0);\n\t\ttranslateTransitionRight.setToX(+80.0);\n\t\ttranslateTransitionRight.setAutoReverse(true);\n\t\ttranslateTransitionRight.setCycleCount(2);\n\t\ttranslateTransitionRight.setInterpolator(Interpolator.LINEAR);\n\n\t\tvar sequentialTransition = new SequentialTransition(translateTransitionLeft, translateTransitionRight);\n\t\tsequentialTransition.setNode(shareId);\n\t\tsequentialTransition.setCycleCount(Animation.INDEFINITE);\n\n\t\tUiUtils.setOnPrimaryMouseDoubleClicked(logo, _ -> {\n\t\t\tif (rotateTransition.getStatus() == Animation.Status.RUNNING)\n\t\t\t{\n\t\t\t\trotateTransition.jumpTo(javafx.util.Duration.millis(0));\n\t\t\t\trotateTransition.stop();\n\n\t\t\t\tscaleTransition.jumpTo(javafx.util.Duration.millis(0));\n\t\t\t\tscaleTransition.stop();\n\n\t\t\t\tfadeTransition.jumpTo(javafx.util.Duration.millis(0));\n\t\t\t\tfadeTransition.stop();\n\n\t\t\t\tsequentialTransition.jumpTo(javafx.util.Duration.millis(0));\n\t\t\t\tsequentialTransition.stop();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\trotateTransition.play();\n\t\t\t\tscaleTransition.play();\n\t\t\t\tfadeTransition.play();\n\t\t\t\tsequentialTransition.play();\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void openUrl(String url)\n\t{\n\t\tif (hostServices != null)\n\t\t{\n\t\t\thostServices.showDocument(url);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/TabActivation.java",
    "content": "package io.xeres.ui.controller;\n\npublic interface TabActivation\n{\n\tvoid activate();\n\n\tvoid deactivate();\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/WindowController.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller;\n\n/**\n * Use this interface when building a Window.\n */\npublic interface WindowController extends Controller\n{\n\tdefault void onShowing()\n\t{\n\t\t// default\n\t}\n\n\tdefault void onShown()\n\t{\n\t\t// default\n\t}\n\n\tdefault void onHiding()\n\t{\n\t\t// default\n\t}\n\n\tdefault void onHidden()\n\t{\n\t\t// default\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/about/AboutWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.about;\n\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport jakarta.annotation.Nullable;\nimport javafx.application.HostServices;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.TabPane;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.text.Text;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.boot.info.BuildProperties;\nimport org.springframework.core.env.Environment;\nimport org.springframework.stereotype.Component;\n\nimport java.io.IOException;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\nimport java.util.stream.Collectors;\n\nimport static org.apache.commons.lang3.ArrayUtils.isEmpty;\n\n@Component\n@FxmlView(value = \"/view/about/about.fxml\")\npublic class AboutWindowController implements WindowController\n{\n\t@FXML\n\tprivate Button closeWindow;\n\n\t@FXML\n\tprivate TabPane infoPane;\n\n\t@FXML\n\tprivate Text license;\n\n\t@FXML\n\tprivate Label version;\n\n\t@FXML\n\tprivate Label profile;\n\n\t@FXML\n\tprivate ImageView logo;\n\n\tprivate final BuildProperties buildProperties;\n\tprivate final Environment environment;\n\tprivate final HostServices hostServices;\n\tprivate final ResourceBundle bundle;\n\n\tpublic AboutWindowController(BuildProperties buildProperties, Environment environment, @SuppressWarnings(\"SpringJavaInjectionPointsAutowiringInspection\") @Nullable HostServices hostServices, ResourceBundle bundle)\n\t{\n\t\tthis.buildProperties = buildProperties;\n\t\tthis.environment = environment;\n\t\tthis.hostServices = hostServices;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize() throws IOException\n\t{\n\t\tversion.setText(buildProperties.getVersion());\n\t\tif (isEmpty(environment.getActiveProfiles()))\n\t\t{\n\t\t\tprofile.setText(bundle.getString(\"about.release\"));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tprofile.setText(bundle.getString(\"about.profiles\") + \" \" + String.join(\", \", environment.getActiveProfiles()));\n\t\t}\n\t\tlicense.setText(UiUtils.getResourceFileAsString(AboutWindowController.class.getResourceAsStream(\"/LICENSE\")));\n\n\t\tcloseWindow.setOnAction(UiUtils::closeWindow);\n\t\tUiUtils.linkify(infoPane, hostServices);\n\n\t\tUiUtils.setOnPrimaryMouseDoubleClicked(logo, _ -> {\n\t\t\tlogo.setImage(new Image(Objects.requireNonNull(AboutWindowController.class.getResourceAsStream(\"/image/egg.png\"))));\n\t\t\tTooltipUtils.install(logo, \"Qrqvpngrq gb Lhyvn\\u001F Nqevra\\u001F Nyvan naq Kravn\".chars().mapToObj(v -> (char) v).map(c -> (char) ((c < 'a') ? ((c - 'A' + 13) % 26) + 'A' : ((c - 'a' + 13) % 26) + 'a')).map(String::valueOf).collect(Collectors.joining()).replace(\"-\", \" \"));\n\t\t});\n\n\t\tPlatform.runLater(() -> closeWindow.requestFocus());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/account/AccountCreationWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.account;\n\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.client.ProfileClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport javafx.event.EventHandler;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.*;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyCodeCombination;\nimport javafx.scene.input.KeyCombination;\nimport javafx.scene.input.KeyEvent;\nimport javafx.stage.FileChooser;\nimport javafx.stage.FileChooser.ExtensionFilter;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\nimport static javafx.scene.control.Alert.AlertType.ERROR;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Component\n@FxmlView(value = \"/view/account/account_creation.fxml\")\npublic class AccountCreationWindowController implements WindowController\n{\n\tprivate static final KeyCombination HELP_SHORTCUT = new KeyCodeCombination(\n\t\t\tKeyCode.F1\n\t);\n\n\tprivate EventHandler<KeyEvent> keyEventHandler;\n\n\t@FXML\n\tprivate Button okButton;\n\n\t@FXML\n\tprivate Button helpButton;\n\n\t@FXML\n\tprivate TextField profileName;\n\n\t@FXML\n\tprivate TextField locationName;\n\n\t@FXML\n\tprivate ProgressIndicator progress;\n\n\t@FXML\n\tprivate Label status;\n\n\t@FXML\n\tprivate TitledPane titledPane;\n\n\t@FXML\n\tprivate Button importBackup;\n\n\tprivate final ConfigClient configClient;\n\tprivate final ProfileClient profileClient;\n\tprivate final WindowManager windowManager;\n\tprivate final ResourceBundle bundle;\n\n\tpublic AccountCreationWindowController(ConfigClient configClient, ProfileClient profileClient, WindowManager windowManager, ResourceBundle bundle)\n\t{\n\t\tthis.configClient = configClient;\n\t\tthis.profileClient = profileClient;\n\t\tthis.windowManager = windowManager;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tprofileName.textProperty().addListener(_ -> okButton.setDisable(profileName.getText().isBlank()));\n\t\tlocationName.textProperty().addListener(_ -> okButton.setDisable(locationName.getText().isBlank()));\n\n\t\tconfigClient.getUsername()\n\t\t\t\t.doOnSuccess(usernameResult -> Platform.runLater(() -> {\n\t\t\t\t\tassert usernameResult != null;\n\t\t\t\t\tprofileName.setText(usernameResult.username());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tconfigClient.getHostname()\n\t\t\t\t.doOnSuccess(hostnameResult -> Platform.runLater(() -> {\n\t\t\t\t\tassert hostnameResult != null;\n\t\t\t\t\tlocationName.setText(sanitizeHostname(hostnameResult.hostname()));\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tokButton.setOnAction(_ ->\n\t\t{\n\t\t\tvar profileNameText = profileName.getText();\n\t\t\tvar locationNameText = locationName.getText();\n\t\t\tif (isNotBlank(profileNameText) && isNotBlank(locationNameText))\n\t\t\t{\n\t\t\t\tgenerateProfileAndLocation(profileNameText, locationNameText);\n\t\t\t}\n\t\t});\n\n\t\timportBackup.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"account.generation.profile-load\"));\n\t\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\t\tfileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString(\"file-requester.profiles\"), \"*.xml\", \"*.gpg\", \"*.asc\"));\n\t\t\tvar selectedFile = fileChooser.showOpenDialog(UiUtils.getWindow(event));\n\t\t\tif (selectedFile != null && selectedFile.canRead())\n\t\t\t{\n\t\t\t\tif (selectedFile.getPath().endsWith(\".xml\"))\n\t\t\t\t{\n\t\t\t\t\tstatus.setText(bundle.getString(\"account.generation.import.progress\"));\n\t\t\t\t\tsetInProgress(true);\n\t\t\t\t\tconfigClient.sendBackup(selectedFile)\n\t\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> Platform.runLater(this::openDashboard)))\n\t\t\t\t\t\t\t.doOnError(throwable -> {\n\t\t\t\t\t\t\t\tUiUtils.webAlertError(throwable);\n\t\t\t\t\t\t\t\tsetInProgress(false);\n\t\t\t\t\t\t\t\tstatus.setText(null);\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t}\n\t\t\t\telse if (selectedFile.getPath().endsWith(\".gpg\") || selectedFile.getPath().endsWith(\".asc\"))\n\t\t\t\t{\n\t\t\t\t\tstatus.setText(bundle.getString(\"account.generation.import.progress\"));\n\t\t\t\t\tsetInProgress(true);\n\t\t\t\t\tvar dialog = new TextInputDialog();\n\t\t\t\t\tdialog.setTitle(bundle.getString(\"account.generation.import.confirm.title\"));\n\t\t\t\t\tdialog.setHeaderText(null);\n\t\t\t\t\tdialog.setContentText(bundle.getString(\"account.generation.import.confirm.prompt\"));\n\t\t\t\t\tdialog.initOwner(UiUtils.getWindow(event));\n\t\t\t\t\tdialog.showAndWait().ifPresent(response -> configClient.sendRsKeyring(selectedFile, locationName.getText(), response)\n\t\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> Platform.runLater(this::openDashboard)))\n\t\t\t\t\t\t\t.doOnError(throwable -> {\n\t\t\t\t\t\t\t\tUiUtils.webAlertError(throwable);\n\t\t\t\t\t\t\t\tsetInProgress(false);\n\t\t\t\t\t\t\t\tstatus.setText(null);\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.subscribe());\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tUiUtils.showAlert(ERROR, bundle.getString(\"account.generation.import.unknown\"));\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tkeyEventHandler = event -> {\n\t\t\tif (HELP_SHORTCUT.match(event))\n\t\t\t{\n\t\t\t\twindowManager.openDocumentation(false);\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t};\n\t\thelpButton.setOnAction(_ -> windowManager.openDocumentation(false));\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tgetWindow(okButton).addEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler);\n\t\tgetWindow(okButton).setOnCloseRequest(_ -> Platform.exit());\n\t}\n\n\t@Override\n\tpublic void onHiding()\n\t{\n\t\tgetWindow(okButton).removeEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler);\n\t}\n\n\t/**\n\t * Try to make the hostname better by removing the domain part, if present.\n\t * For example, bar.foo.baz -> bar\n\t *\n\t * @param hostname a hostname\n\t * @return a hostname without the domain part\n\t */\n\tprivate static String sanitizeHostname(String hostname)\n\t{\n\t\treturn hostname.split(\"\\\\.\")[0];\n\t}\n\n\tprivate void setInProgress(boolean inProgress)\n\t{\n\t\tokButton.setDisable(inProgress);\n\t\tprofileName.setDisable(inProgress);\n\t\tlocationName.setDisable(inProgress);\n\t\timportBackup.setDisable(inProgress);\n\t\tprogress.setVisible(inProgress);\n\t\ttitledPane.setExpanded(!inProgress);\n\t}\n\n\tpublic void generateProfileAndLocation(String profileName, String locationName)\n\t{\n\t\tsetInProgress(true);\n\n\t\tstatus.setText(bundle.getString(\"account.generation.profile-keys\"));\n\n\t\tconfigClient.createProfile(profileName).doOnSuccess(_ -> Platform.runLater(() -> generateLocation(profileName, locationName)))\n\t\t\t\t.doOnError(e -> Platform.runLater(() -> {\n\t\t\t\t\tUiUtils.webAlertError(e);\n\t\t\t\t\tsetInProgress(false);\n\t\t\t\t\tstatus.setText(null);\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void generateLocation(String profileName, String locationName)\n\t{\n\t\tsetInProgress(true);\n\n\t\tstatus.setText(bundle.getString(\"account.generation.location-keys-and-certificate\"));\n\n\t\tconfigClient.createLocation(locationName).doOnSuccess(_ -> Platform.runLater(() -> generateIdentity(profileName)))\n\t\t\t\t.doOnError(e -> Platform.runLater(() -> {\n\t\t\t\t\tUiUtils.webAlertError(e);\n\t\t\t\t\tsetInProgress(false);\n\t\t\t\t\tstatus.setText(null);\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void generateIdentity(String identityName)\n\t{\n\t\tsetInProgress(true);\n\n\t\tvar result = configClient.createIdentity(identityName, false);\n\n\t\tstatus.setText(bundle.getString(\"account.generation.identity\"));\n\n\t\tresult.doOnSuccess(_ -> Platform.runLater(this::openDashboard))\n\t\t\t\t.doOnError(e -> Platform.runLater(() -> {\n\t\t\t\t\tUiUtils.webAlertError(e);\n\t\t\t\t\tsetInProgress(false);\n\t\t\t\t\tstatus.setText(null);\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void openDashboard()\n\t{\n\t\tprofileClient.getOwn().doOnSuccess(profile -> Platform.runLater(() -> {\n\t\t\t\t\twindowManager.openMain(null, profile, false);\n\t\t\t\t\tgetWindow(profileName).hide();\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/board/BoardGroupCell.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.board;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.custom.asyncimage.PlaceholderImageView;\nimport io.xeres.ui.model.board.BoardGroup;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TreeTableCell;\nimport javafx.scene.image.ImageView;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\n\npublic class BoardGroupCell extends TreeTableCell<BoardGroup, BoardGroup>\n{\n\tprivate static final int IMAGE_WIDTH = 32;\n\tprivate static final int IMAGE_HEIGHT = 32;\n\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tpublic BoardGroupCell(GeneralClient generalClient, ImageCache imageCache)\n\t{\n\t\tsuper();\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tTooltipUtils.install(this,\n\t\t\t\t() -> MessageFormat.format(bundle.getString(\"gxs-group.tree.info\"),\n\t\t\t\t\t\tgetItem().getName(),\n\t\t\t\t\t\tgetItem().getGxsId(),\n\t\t\t\t\t\tgetItem().getVisibleMessageCount(),\n\t\t\t\t\t\tDateUtils.formatDateTime(getItem().getLastActivity(), bundle.getString(\"unknown-lc\"))),\n\t\t\t\t() -> new ImageView(((PlaceholderImageView) getGraphic()).getImage()));\n\t}\n\n\t@Override\n\tprotected void updateItem(BoardGroup item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.getName());\n\t\tsetGraphic(empty ? null : updateImage((PlaceholderImageView) getGraphic(), item));\n\t}\n\n\tprivate PlaceholderImageView updateImage(PlaceholderImageView placeholderImageView, BoardGroup item)\n\t{\n\t\tif (placeholderImageView == null)\n\t\t{\n\t\t\tplaceholderImageView = new PlaceholderImageView(\n\t\t\t\t\turl -> generalClient.getImage(url).block(),\n\t\t\t\t\t\"mdi2v-view-dashboard-outline\",\n\t\t\t\t\timageCache);\n\t\t}\n\t\tif (item.isReal())\n\t\t{\n\t\t\tplaceholderImageView.setFitWidth(IMAGE_WIDTH);\n\t\t\tplaceholderImageView.setFitHeight(IMAGE_HEIGHT);\n\t\t\tplaceholderImageView.setUrl(getImageUrl(item));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tplaceholderImageView.setFitWidth(0);\n\t\t\tplaceholderImageView.setFitHeight(0);\n\t\t\tplaceholderImageView.setUrl(null);\n\t\t\tplaceholderImageView.hideDefault(); // SetUrl(null) shows a default, but we don't want one as we're tree group nodes\n\t\t}\n\t\treturn placeholderImageView;\n\t}\n\n\tprivate String getImageUrl(BoardGroup item)\n\t{\n\t\tif (item.isReal() && item.hasImage())\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + BOARDS_PATH + \"/groups/\" + item.getId() + \"/image\";\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/board/BoardGroupWindowController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.board;\n\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.BoardClient;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.ImageSelectorView;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.event.ActionEvent;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.ProgressBar;\nimport javafx.scene.control.TextField;\nimport javafx.stage.FileChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.lang3.Strings;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\n\n@Component\n@FxmlView(value = \"/view/board/board_group_view.fxml\")\npublic class BoardGroupWindowController implements WindowController\n{\n\t@FXML\n\tprivate Button createOrUpdateButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\t@FXML\n\tprivate TextField boardName;\n\n\t@FXML\n\tprivate TextField boardDescription;\n\n\t@FXML\n\tprivate ImageSelectorView boardLogo;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\tprivate final BoardClient boardClient;\n\tprivate final GeneralClient generalClient;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate long boardId;\n\n\tprivate String initialUrl;\n\tprivate String initialName;\n\tprivate String initialDescription;\n\n\tpublic BoardGroupWindowController(BoardClient boardClient, GeneralClient generalClient, ResourceBundle bundle)\n\t{\n\t\tthis.boardClient = boardClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tboardName.textProperty().addListener(_ -> checkCreatableOrUpdatable());\n\t\tboardDescription.textProperty().addListener(_ -> checkCreatableOrUpdatable());\n\t\tboardLogo.imageProperty().addListener(_ -> checkCreatableOrUpdatable());\n\t\tboardLogo.setOnSelectAction(this::selectGroupImage);\n\t\tboardLogo.setOnDeleteAction(this::clearGroupImage);\n\t\tboardLogo.setImageLoader(url -> generalClient.getImage(url).block());\n\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(boardName);\n\t\tif (userData != null)\n\t\t{\n\t\t\tboardId = (long) userData;\n\t\t}\n\n\t\tif (boardId != 0L)\n\t\t{\n\t\t\tboardClient.getBoardGroupById(boardId)\n\t\t\t\t\t.doOnSuccess(boardGroup -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert boardGroup != null;\n\t\t\t\t\t\tboardName.setText(boardGroup.getName());\n\t\t\t\t\t\tboardDescription.setText(boardGroup.getDescription());\n\t\t\t\t\t\tif (boardGroup.hasImage())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tboardLogo.setImageUrl(RemoteUtils.getControlUrl() + BOARDS_PATH + \"/groups/\" + boardGroup.getId() + \"/image\");\n\t\t\t\t\t\t\tinitialUrl = boardLogo.getUrl();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tinitialName = boardName.getText();\n\t\t\t\t\t\tinitialDescription = boardDescription.getText();\n\t\t\t\t\t\tcreateOrUpdateButton.setDisable(true);\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t\tcreateOrUpdateButton.setText(bundle.getString(\"update\"));\n\t\t\tcreateOrUpdateButton.setOnAction(_ -> {\n\t\t\t\tsetWaiting(true);\n\t\t\t\tboardClient.updateBoardGroup(boardId,\n\t\t\t\t\t\t\t\tboardName.getText(),\n\t\t\t\t\t\t\t\tboardDescription.getText(),\n\t\t\t\t\t\t\t\tboardLogo.getFile(),\n\t\t\t\t\t\t\t\t!Strings.CS.equals(initialUrl, boardLogo.getUrl()))\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(boardName)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t\t\t.subscribe();\n\t\t\t});\n\t\t}\n\t\telse\n\t\t{\n\t\t\tcreateOrUpdateButton.setOnAction(_ -> {\n\t\t\t\tsetWaiting(true);\n\t\t\t\tboardClient.createBoardGroup(boardName.getText(),\n\t\t\t\t\t\t\t\tboardDescription.getText(),\n\t\t\t\t\t\t\t\tboardLogo.getFile())\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(boardName)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t\t\t.subscribe();\n\t\t\t});\n\t\t}\n\t}\n\n\tprivate void setWaiting(boolean waiting)\n\t{\n\t\tboardName.setDisable(waiting);\n\t\tboardDescription.setDisable(waiting);\n\t\tboardLogo.setDisable(waiting);\n\t\tcreateOrUpdateButton.setDisable(waiting);\n\t\tcancelButton.setDisable(waiting);\n\t\tUiUtils.setPresent(progressBar, waiting);\n\t}\n\n\tprivate void checkCreatableOrUpdatable()\n\t{\n\t\tcreateOrUpdateButton.setDisable((boardId == 0L && boardName.getText().isBlank()) ||\n\t\t\t\t(boardId == 0L && boardDescription.getText().isBlank()) ||\n\t\t\t\t(\n\t\t\t\t\t\tStrings.CS.equals(initialName, boardName.getText()) &&\n\t\t\t\t\t\t\t\tStrings.CS.equals(initialDescription, boardDescription.getText()) &&\n\t\t\t\t\t\t\t\tStrings.CS.equals(initialUrl, boardLogo.getUrl())\n\t\t\t\t)\n\t\t);\n\t}\n\n\tprivate void selectGroupImage(ActionEvent event)\n\t{\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"board.select-logo\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tChooserUtils.setSupportedLoadImageFormats(fileChooser);\n\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\tboardLogo.setFile(selectedFile);\n\t}\n\n\tprivate void clearGroupImage(ActionEvent event)\n\t{\n\t\tboardLogo.setImage(null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/board/BoardMessageCell.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.board;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.BoardClient;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.model.board.BoardMessage;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.markdown.MarkdownService.Rendering;\nimport io.xeres.ui.support.uri.UriFactory;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.TextFlowDragSelection;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Node;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.ToggleButton;\nimport javafx.scene.layout.VBox;\nimport javafx.scene.text.TextFlow;\nimport org.fxmisc.flowless.Cell;\n\nimport java.io.IOException;\nimport java.util.EnumSet;\n\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\n\nclass BoardMessageCell implements Cell<BoardMessage, Node>\n{\n\t@FXML\n\tprivate VBox groupView;\n\n\t@FXML\n\tprivate TextFlow titleFlow;\n\n\t@FXML\n\tprivate TextFlow contentFlow;\n\n\t@FXML\n\tprivate Label authorLabel;\n\n\t@FXML\n\tprivate Label postInstantLabel;\n\n\t@FXML\n\tprivate ToggleButton unreadButton;\n\n\t@FXML\n\tprivate AsyncImageView imageView;\n\n\tprivate final MarkdownService markdownService;\n\n\tpublic BoardMessageCell(BoardMessage boardMessage, GeneralClient generalClient, BoardClient boardClient, MarkdownService markdownService)\n\t{\n\t\tthis.markdownService = markdownService;\n\n\t\tvar loader = new FXMLLoader(BoardMessageCell.class.getResource(\"/view/board/message_cell.fxml\"), I18nUtils.getBundle());\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\n\t\timageView.setLoader(url -> generalClient.getImage(url).block());\n\t\tImageViewUtils.addImageContextMenuActions(imageView);\n\t\tTextFlowDragSelection.enableSelection(contentFlow, null);\n\n\t\tunreadButton.setOnAction(_ -> {\n\t\t\tvar item = (BoardMessage) unreadButton.getUserData();\n\t\t\tboardClient.setBoardMessageReadState(item.getId(), !unreadButton.isSelected())\n\t\t\t\t\t.subscribe();\n\t\t});\n\n\t\tupdateItem(boardMessage);\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn groupView;\n\t}\n\n\t@Override\n\tpublic boolean isReusable()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic void updateItem(BoardMessage item)\n\t{\n\t\ttitleFlow.getChildren().clear();\n\t\tif (item.hasLink())\n\t\t{\n\t\t\tvar content = UriFactory.createContent(item.getLink(), item.getName(), markdownService.getUriService());\n\t\t\ttitleFlow.getChildren().addAll(content.getNode());\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttitleFlow.getChildren().add(new Label(item.getName()));\n\t\t}\n\n\t\tcontentFlow.getChildren().clear();\n\t\tif (item.hasContent())\n\t\t{\n\t\t\tcontentFlow.getChildren().addAll(markdownService.parse(item.getContent(), EnumSet.noneOf(Rendering.class)).stream()\n\t\t\t\t\t.map(Content::getNode).toList());\n\t\t\tUiUtils.setPresent(contentFlow);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tUiUtils.setAbsent(contentFlow);\n\t\t}\n\n\t\tauthorLabel.setText(item.getAuthorName());\n\t\tpostInstantLabel.setText(DateUtils.DATE_TIME_FORMAT.format(item.getPublished()));\n\t\tunreadButton.setSelected(!item.isRead());\n\t\tunreadButton.setUserData(item);\n\t\tUiUtils.setPresent(imageView, item.hasImage());\n\t\tif (item.hasImage() && item.getImageWidth() > 0 && item.getImageHeight() > 0)\n\t\t{\n\t\t\t// Improve layout by knowing the dimension in advance.\n\t\t\timageView.setFitWidth(item.getImageWidth());\n\t\t\timageView.setFitHeight(item.getImageHeight());\n\t\t}\n\t\telse\n\t\t{\n\t\t\timageView.setFitWidth(0);\n\t\t\timageView.setFitHeight(0);\n\t\t}\n\t\timageView.setUrl(getImageUrl(item));\n\t}\n\n\tprivate String getImageUrl(BoardMessage item)\n\t{\n\t\tif (item.hasImage())\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + BOARDS_PATH + \"/messages/\" + item.getId() + \"/image\";\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/board/BoardMessageWindowController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.board;\n\nimport atlantafx.base.controls.Tab;\nimport atlantafx.base.controls.TabLine;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.BoardClient;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.EditorView;\nimport io.xeres.ui.custom.ImageSelectorView;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.event.ActionEvent;\nimport javafx.fxml.FXML;\nimport javafx.scene.Node;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.ProgressBar;\nimport javafx.scene.control.TextField;\nimport javafx.scene.layout.GridPane;\nimport javafx.stage.FileChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\n\n@Component\n@FxmlView(value = \"/view/board/board_message_view.fxml\")\npublic class BoardMessageWindowController implements WindowController\n{\n\t@FXML\n\tprivate GridPane gridPane;\n\n\t@FXML\n\tprivate TextField boardName;\n\n\t@FXML\n\tprivate TextField title;\n\n\t@FXML\n\tprivate TabLine tabLine;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\t@FXML\n\tprivate Tab textTab;\n\n\t@FXML\n\tprivate Tab imageTab;\n\n\t@FXML\n\tprivate Tab linkTab;\n\n\t@FXML\n\tprivate EditorView editorView;\n\n\t@FXML\n\tprivate Button send;\n\n\tprivate final List<Node> addedNodes = new ArrayList<>();\n\n\tprivate ImageSelectorView imageSelectorView;\n\n\tprivate Label linkLabel;\n\n\tprivate TextField linkTextField;\n\n\tprivate long boardId;\n\n\tprivate final BoardClient boardClient;\n\tprivate final LocationClient locationClient;\n\tprivate final MarkdownService markdownService;\n\tprivate final ResourceBundle bundle;\n\n\tpublic BoardMessageWindowController(BoardClient boardClient, LocationClient locationClient, MarkdownService markdownService, ResourceBundle bundle)\n\t{\n\t\tthis.boardClient = boardClient;\n\t\tthis.locationClient = locationClient;\n\t\tthis.markdownService = markdownService;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\timageSelectorView = new ImageSelectorView(240.0, 180.0, \"mdi2i-image\", true);\n\t\timageSelectorView.setOnSelectAction(this::selectMessageImage);\n\t\timageSelectorView.setOnDeleteAction(this::clearMessageImage);\n\t\tlinkLabel = new Label(\"URL\");\n\t\tlinkTextField = new TextField();\n\n\t\ttabLine.setTabClosingPolicy(Tab.ClosingPolicy.NO_TABS);\n\t\ttabLine.setTabDragPolicy(Tab.DragPolicy.FIXED);\n\t\ttabLine.setTabResizePolicy(Tab.ResizePolicy.ADAPTIVE);\n\n\t\ttabLine.getSelectionModel().selectedItemProperty().subscribe(tab -> {\n\t\t\tclearPanelContent();\n\t\t\tif (tab == imageTab)\n\t\t\t{\n\t\t\t\tgridPane.add(addPanelContent(imageSelectorView), 0, 3, 2, 1);\n\t\t\t}\n\t\t\telse if (tab == linkTab)\n\t\t\t{\n\t\t\t\tgridPane.add(addPanelContent(linkLabel), 0, 3);\n\t\t\t\tgridPane.add(addPanelContent(linkTextField), 1, 3);\n\t\t\t}\n\t\t});\n\n\t\tPlatform.runLater(() -> title.requestFocus());\n\n\t\teditorView.setInputContextMenu(locationClient);\n\t\teditorView.setMarkdownService(markdownService);\n\t\ttitle.setOnKeyTyped(_ -> checkSendable());\n\n\t\tsend.setOnAction(_ -> postMessage());\n\t}\n\n\tprivate Node addPanelContent(Node node)\n\t{\n\t\taddedNodes.add(node);\n\t\treturn node;\n\t}\n\n\tprivate void clearPanelContent()\n\t{\n\t\taddedNodes.forEach(node -> gridPane.getChildren().remove(node));\n\t\taddedNodes.clear();\n\t}\n\n\tprivate void checkSendable()\n\t{\n\t\tsend.setDisable(StringUtils.isBlank(title.getText()));\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(title);\n\t\tif (userData == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing board id\");\n\t\t}\n\n\t\tboardId = (long) userData;\n\n\t\tboardClient.getBoardGroupById(boardId)\n\t\t\t\t.doOnSuccess(boardGroup -> Platform.runLater(() -> {\n\t\t\t\t\tassert boardGroup != null;\n\t\t\t\t\tboardName.setText(boardGroup.getName());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\t// Prevent the message from being discarded by mistake\n\t\tUiUtils.getWindow(send).setOnCloseRequest(event -> {\n\t\t\tif (!title.getText().isBlank() || editorView.isModified() || !imageSelectorView.isEmpty() || !linkTextField.getText().isBlank())\n\t\t\t{\n\t\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"board.editor.cancel\"), () -> UiUtils.getWindow(send).hide());\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void setWaiting(boolean waiting)\n\t{\n\t\ttabLine.setDisable(waiting);\n\t\ttitle.setDisable(waiting);\n\t\teditorView.setDisable(waiting);\n\t\tsend.setDisable(waiting);\n\t\tUiUtils.setPresent(progressBar, waiting);\n\t}\n\n\tprivate void postMessage()\n\t{\n\t\tsetWaiting(true);\n\t\tboardClient.createBoardMessage(boardId, title.getText(), editorView.getText(), linkTextField.getText(), imageSelectorView.getFile())\n\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(send)))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void selectMessageImage(ActionEvent event)\n\t{\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"board.select-image\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tChooserUtils.setSupportedLoadImageFormats(fileChooser);\n\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\timageSelectorView.setFile(selectedFile);\n\t}\n\n\tprivate void clearMessageImage(ActionEvent event)\n\t{\n\t\timageSelectorView.setImage(null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/board/BoardViewController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.board;\n\nimport io.xeres.common.gxs.GxsGroupConstants;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.rest.notification.board.AddOrUpdateBoardGroups;\nimport io.xeres.common.rest.notification.board.AddOrUpdateBoardMessages;\nimport io.xeres.common.rest.notification.board.SetBoardGroupMessagesReadState;\nimport io.xeres.common.rest.notification.board.SetBoardMessageReadState;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.BoardClient;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.common.GxsGroupTreeTableAction;\nimport io.xeres.ui.controller.common.GxsGroupTreeTableView;\nimport io.xeres.ui.custom.InfoView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.event.UnreadEvent;\nimport io.xeres.ui.model.board.BoardGroup;\nimport io.xeres.ui.model.board.BoardMapper;\nimport io.xeres.ui.model.board.BoardMessage;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.loader.OnDemandLoader;\nimport io.xeres.ui.support.loader.OnDemandLoaderAction;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.unread.UnreadService;\nimport io.xeres.ui.support.uri.BoardUri;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.fxml.FXML;\nimport javafx.scene.Node;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.SplitPane;\nimport javafx.scene.input.ScrollEvent;\nimport javafx.scene.layout.Priority;\nimport javafx.scene.layout.StackPane;\nimport javafx.scene.layout.VBox;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.fxmisc.flowless.VirtualFlow;\nimport org.fxmisc.flowless.VirtualizedScrollPane;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport java.util.*;\n\nimport static io.xeres.common.rest.PathConfig.BOARDS_PATH;\nimport static io.xeres.ui.support.preference.PreferenceUtils.BOARDS;\nimport static javafx.scene.control.Alert.AlertType.WARNING;\n\n@Component\n@FxmlView(value = \"/view/board/board_view.fxml\")\npublic class BoardViewController implements Controller, GxsGroupTreeTableAction<BoardGroup>, OnDemandLoaderAction<BoardGroup>\n{\n\tprivate final WindowManager windowManager;\n\n\t@FXML\n\tprivate GxsGroupTreeTableView<BoardGroup> boardTree;\n\n\t@FXML\n\tprivate SplitPane splitPaneVertical;\n\n\t@FXML\n\tprivate Button createBoard;\n\n\t@FXML\n\tprivate Button newPost;\n\n\t@FXML\n\tprivate StackPane contentGroup;\n\n\tprivate InfoView infoView;\n\n\tprivate final ObservableList<BoardMessage> messages = FXCollections.observableArrayList();\n\n\tprivate OnDemandLoader<BoardGroup, BoardMessage> onDemandLoader;\n\n\tprivate VirtualFlow<BoardMessage, BoardMessageCell> virtualFlow;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate final BoardClient boardClient;\n\tprivate final NotificationClient notificationClient;\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCacheService;\n\tprivate final UnreadService unreadService;\n\tprivate final MarkdownService markdownService;\n\tprivate final ImageCache imageCache;\n\n\tprivate Disposable notificationDisposable;\n\n\tprivate UrlToOpen urlToOpen;\n\n\tpublic BoardViewController(BoardClient boardClient, ResourceBundle bundle, NotificationClient notificationClient, GeneralClient generalClient, ImageCache imageCacheService, UnreadService unreadService, MarkdownService markdownService, WindowManager windowManager, ImageCache imageCache)\n\t{\n\t\tthis.boardClient = boardClient;\n\t\tthis.bundle = bundle;\n\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCacheService = imageCacheService;\n\t\tthis.unreadService = unreadService;\n\t\tthis.markdownService = markdownService;\n\t\tthis.windowManager = windowManager;\n\t\tthis.imageCache = imageCache;\n\t}\n\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tboardTree.initialize(BOARDS,\n\t\t\t\tboardClient,\n\t\t\t\tBoardGroup::new,\n\t\t\t\t() -> new BoardGroupCell(generalClient, imageCacheService),\n\t\t\t\tthis);\n\n\t\tboardTree.unreadProperty().addListener((_, _, newValue) -> unreadService.sendUnreadEvent(UnreadEvent.Element.BOARD, newValue));\n\n\t\t// VirtualizedScrollPane doesn't work from FXML so we add it manually\n\t\tvirtualFlow = VirtualFlow.createVertical(messages, boardMessage -> new BoardMessageCell(boardMessage, generalClient, boardClient, markdownService));\n\t\tVirtualizedScrollPane<VirtualFlow<BoardMessage, BoardMessageCell>> messagesView = new VirtualizedScrollPane<>(virtualFlow);\n\t\tVBox.setVgrow(messagesView, Priority.ALWAYS);\n\t\tcontentGroup.getChildren().add(messagesView);\n\n\t\t// Create InfoView to display group info\n\t\tinfoView = new InfoView();\n\t\tinfoView.setLoader(url -> generalClient.getImage(url).block());\n\t\tcontentGroup.getChildren().add(infoView);\n\t\tinfoView.setVisible(false);\n\n\t\tonDemandLoader = new OnDemandLoader<>(messagesView, messages, boardClient, this);\n\n\t\t// The default handler is a bit slow, let's speed up\n\t\t// mouse scrolling.\n\t\tmessagesView.addEventFilter(ScrollEvent.ANY, se -> {\n\t\t\tmessagesView.scrollXBy(-se.getDeltaX());\n\t\t\tmessagesView.scrollYBy(-se.getDeltaY() * 4);\n\t\t\tse.consume();\n\t\t});\n\n\t\tcreateBoard.setOnAction(_ -> windowManager.openBoardCreation(0L));\n\n\t\tnewPost.setOnAction(_ -> newBoardPost());\n\n\t\tsetupBoardNotifications();\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvent(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof BoardUri boardUri)\n\t\t{\n\t\t\tif (!boardTree.openUrl(boardUri.gxsId(), boardUri.msgId()))\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(WARNING, bundle.getString(\"board.view.group.not-found\"));\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onSubscribeToGroup(BoardGroup group)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void onUnsubscribeFromGroup(BoardGroup group)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void onCopyGroupLink(BoardGroup group)\n\t{\n\t\tvar boardUri = new BoardUri(group.getName(), group.getGxsId(), null);\n\t\tClipboardUtils.copyTextToClipboard(boardUri.toUriString());\n\t}\n\n\t@Override\n\tpublic void onOpenUrl(GxsId gxsId, MsgId msgId)\n\t{\n\t\tif (gxsId.equals(boardTree.getSelectedGroupGxsId()))\n\t\t{\n\t\t\tselectMessage(msgId);\n\t\t}\n\t\telse\n\t\t{\n\t\t\turlToOpen = new UrlToOpen(gxsId, msgId);\n\t\t}\n\t}\n\n\tprivate void selectMessage(MsgId msgId)\n\t{\n\t\tfor (var i = 0; i < messages.size(); i++)\n\t\t{\n\t\t\tvar message = messages.get(i);\n\t\t\tif (message.getMsgId().equals(msgId))\n\t\t\t{\n\t\t\t\tvirtualFlow.show(i);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onSelectSubscribedGroup(BoardGroup group)\n\t{\n\t\tonDemandLoader.changeSelection(group);\n\t\tnewPost.setDisable(false);\n\t\tshowGroupInfo(null);\n\t}\n\n\t@Override\n\tpublic void onSelectUnsubscribedGroup(BoardGroup group)\n\t{\n\t\tonDemandLoader.changeSelection(group);\n\t\tnewPost.setDisable(true);\n\t\tshowGroupInfo(group);\n\t}\n\n\t@Override\n\tpublic void onUnselectGroup()\n\t{\n\t\tonDemandLoader.changeSelection(null);\n\t\tnewPost.setDisable(true);\n\t\tshowGroupInfo(null);\n\t}\n\n\t@Override\n\tpublic void onEditGroup(BoardGroup group)\n\t{\n\t\twindowManager.openBoardCreation(group.getId());\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (notificationDisposable != null && !notificationDisposable.isDisposed())\n\t\t{\n\t\t\tnotificationDisposable.dispose();\n\t\t}\n\t}\n\n\tprivate void setupBoardNotifications()\n\t{\n\t\tnotificationDisposable = notificationClient.getBoardNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tswitch (sse.data())\n\t\t\t\t\t{\n\t\t\t\t\t\tcase AddOrUpdateBoardGroups action ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\taction.boardGroups().forEach(boardGroupItem -> imageCache.evictImage(RemoteUtils.getControlUrl() + BOARDS_PATH + \"/groups/\" + boardGroupItem.id() + \"/image\"));\n\n\t\t\t\t\t\t\tboardTree.addGroups(action.boardGroups().stream()\n\t\t\t\t\t\t\t\t\t.map(BoardMapper::fromDTO)\n\t\t\t\t\t\t\t\t\t.toList());\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase AddOrUpdateBoardMessages action -> addBoardMessages(action.boardMessages().stream()\n\t\t\t\t\t\t\t\t.map(BoardMapper::fromDTO)\n\t\t\t\t\t\t\t\t.toList());\n\t\t\t\t\t\tcase SetBoardMessageReadState action -> setMessageReadState(action.groupId(), action.messageId(), action.read());\n\t\t\t\t\t\tcase SetBoardGroupMessagesReadState action -> setGroupMessagesReadState(action.groupId(), action.read());\n\t\t\t\t\t\tcase null -> throw new IllegalArgumentException(\"Board notifications have not been set\");\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\tonDemandLoader.setMessageReadState(groupId, messageId, read);\n\t\tboardTree.setUnreadCount(groupId, read);\n\t}\n\n\tprivate void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tonDemandLoader.setGroupMessagesReadState(groupId, read);\n\t\tboardTree.refreshUnreadCount(groupId);\n\t}\n\n\tprivate void newBoardPost()\n\t{\n\t\twindowManager.openBoardMessage(boardTree.getSelectedGroupId());\n\t}\n\n\tprivate void addBoardMessages(List<BoardMessage> boardMessages)\n\t{\n\t\tSet<GxsId> boardsToUpdate = new HashSet<>();\n\n\t\tfor (BoardMessage boardMessage : boardMessages)\n\t\t{\n\t\t\tonDemandLoader.insertMessage(boardMessage);\n\t\t\tboardsToUpdate.add(boardMessage.getGxsId());\n\t\t}\n\t\tboardTree.refreshUnreadCount(boardsToUpdate);\n\t}\n\n\t@Override\n\tpublic void onMessagesLoaded(BoardGroup group)\n\t{\n\t\tif (urlToOpen != null)\n\t\t{\n\t\t\tif (group.getGxsId().equals(urlToOpen.gxsId()))\n\t\t\t{\n\t\t\t\tselectMessage(urlToOpen.msgId());\n\t\t\t\turlToOpen = null;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate List<Node> createContent(String input)\n\t{\n\t\treturn markdownService.parse(input, EnumSet.noneOf(MarkdownService.Rendering.class)).stream()\n\t\t\t\t.map(Content::getNode).toList();\n\t}\n\n\tprivate void showGroupInfo(BoardGroup group)\n\t{\n\t\tif (group != null && group.isReal())\n\t\t{\n\t\t\tvar header = createContent(\"\"\"\n\t\t\t\t\t## %s\n\t\t\t\t\t\n\t\t\t\t\t%s: %s\\\\\n\t\t\t\t\t%s: %s\n\t\t\t\t\t\"\"\".formatted(\n\t\t\t\t\tgroup.getName(),\n\t\t\t\t\tbundle.getString(\"posts-at-remote-nodes\"),\n\t\t\t\t\tgroup.getVisibleMessageCount(),\n\t\t\t\t\tbundle.getString(\"last-activity\"),\n\t\t\t\t\tDateUtils.formatDateTime(group.getLastActivity(), bundle.getString(\"unknown-lc\"))));\n\n\t\t\tvar body = createContent(group.getDescription());\n\n\t\t\tif (group.hasImage())\n\t\t\t{\n\t\t\t\tinfoView.setInfo(header, body, RemoteUtils.getControlUrl() + BOARDS_PATH + \"/groups/\" + group.getId() + \"/image\", GxsGroupConstants.IMAGE_SIDE_SIZE, GxsGroupConstants.IMAGE_SIDE_SIZE);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tinfoView.setInfo(header, body);\n\t\t\t}\n\n\t\t\tinfoView.setVisible(true);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tinfoView.setInfo(null, null);\n\t\t\tinfoView.setVisible(false);\n\t\t}\n\t}\n\n\trecord UrlToOpen(GxsId gxsId, MsgId msgId)\n\t{\n\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelFileSizeCell.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.ui.model.channel.ChannelFile;\nimport javafx.scene.control.TableCell;\n\nclass ChannelFileSizeCell extends TableCell<ChannelFile, Long>\n{\n\t@Override\n\tprotected void updateItem(Long value, boolean empty)\n\t{\n\t\tsuper.updateItem(value, empty);\n\t\tsetText(empty ? null : ByteUnitUtils.fromBytes(value));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelGroupCell.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.custom.asyncimage.PlaceholderImageView;\nimport io.xeres.ui.model.channel.ChannelGroup;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TreeTableCell;\nimport javafx.scene.image.ImageView;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\n\npublic class ChannelGroupCell extends TreeTableCell<ChannelGroup, ChannelGroup>\n{\n\tprivate static final int IMAGE_WIDTH = 32;\n\tprivate static final int IMAGE_HEIGHT = 32;\n\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tpublic ChannelGroupCell(GeneralClient generalClient, ImageCache imageCache)\n\t{\n\t\tsuper();\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tTooltipUtils.install(this,\n\t\t\t\t() -> MessageFormat.format(bundle.getString(\"gxs-group.tree.info\"),\n\t\t\t\t\t\tgetItem().getName(),\n\t\t\t\t\t\tgetItem().getGxsId(),\n\t\t\t\t\t\tgetItem().getVisibleMessageCount(),\n\t\t\t\t\t\tDateUtils.formatDateTime(getItem().getLastActivity(), bundle.getString(\"unknown-lc\"))),\n\t\t\t\t() -> new ImageView(((PlaceholderImageView) super.getGraphic()).getImage()));\n\t}\n\n\t@Override\n\tprotected void updateItem(ChannelGroup item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.getName());\n\t\tsetGraphic(empty ? null : updateImage((PlaceholderImageView) getGraphic(), item));\n\t}\n\n\tprivate PlaceholderImageView updateImage(PlaceholderImageView placeholderImageView, ChannelGroup item)\n\t{\n\t\tif (placeholderImageView == null)\n\t\t{\n\t\t\tplaceholderImageView = new PlaceholderImageView(\n\t\t\t\t\turl -> generalClient.getImage(url).block(),\n\t\t\t\t\t\"mdi2p-play-box\",\n\t\t\t\t\timageCache);\n\t\t}\n\t\tif (item.isReal())\n\t\t{\n\t\t\tplaceholderImageView.setFitWidth(IMAGE_WIDTH);\n\t\t\tplaceholderImageView.setFitHeight(IMAGE_HEIGHT);\n\t\t\tplaceholderImageView.setUrl(getImageUrl(item));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tplaceholderImageView.setFitWidth(0);\n\t\t\tplaceholderImageView.setFitHeight(0);\n\t\t\tplaceholderImageView.setUrl(null);\n\t\t\tplaceholderImageView.hideDefault(); // SetUrl(null) shows a default, but we don't want one as we're tree group nodes\n\t\t}\n\t\treturn placeholderImageView;\n\t}\n\n\tprivate String getImageUrl(ChannelGroup item)\n\t{\n\t\tif (item.isReal() && item.hasImage())\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + CHANNELS_PATH + \"/groups/\" + item.getId() + \"/image\";\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelGroupWindowController.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.ChannelClient;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.ImageSelectorView;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.event.ActionEvent;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.ProgressBar;\nimport javafx.scene.control.TextField;\nimport javafx.stage.FileChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.lang3.Strings;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\n\n@Component\n@FxmlView(value = \"/view/channel/channel_group_view.fxml\")\npublic class ChannelGroupWindowController implements WindowController\n{\n\t@FXML\n\tprivate Button createOrUpdateButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\t@FXML\n\tprivate TextField channelName;\n\n\t@FXML\n\tprivate TextField channelDescription;\n\n\t@FXML\n\tprivate ImageSelectorView channelLogo;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\tprivate final ChannelClient channelClient;\n\tprivate final GeneralClient generalClient;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate long channelId;\n\n\tprivate String initialUrl;\n\tprivate String initialName;\n\tprivate String initialDescription;\n\n\tpublic ChannelGroupWindowController(ChannelClient channelClient, GeneralClient generalClient, ResourceBundle bundle)\n\t{\n\t\tthis.channelClient = channelClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tchannelName.textProperty().addListener(_ -> checkCreatableOrUpdatable());\n\t\tchannelDescription.textProperty().addListener(_ -> checkCreatableOrUpdatable());\n\t\tchannelLogo.imageProperty().addListener(_ -> checkCreatableOrUpdatable());\n\t\tchannelLogo.setOnSelectAction(this::selectGroupImage);\n\t\tchannelLogo.setOnDeleteAction(this::clearGroupImage);\n\t\tchannelLogo.setImageLoader(url -> generalClient.getImage(url).block());\n\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(channelName);\n\t\tif (userData != null)\n\t\t{\n\t\t\tchannelId = (long) userData;\n\t\t}\n\n\t\tif (channelId != 0L)\n\t\t{\n\t\t\tchannelClient.getChannelGroupById(channelId)\n\t\t\t\t\t.doOnSuccess(channelGroup -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert channelGroup != null;\n\t\t\t\t\t\tchannelName.setText(channelGroup.getName());\n\t\t\t\t\t\tchannelDescription.setText(channelGroup.getDescription());\n\t\t\t\t\t\tif (channelGroup.hasImage())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tchannelLogo.setImageUrl(RemoteUtils.getControlUrl() + CHANNELS_PATH + \"/groups/\" + channelGroup.getId() + \"/image\");\n\t\t\t\t\t\t\tinitialUrl = channelLogo.getUrl();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tinitialName = channelName.getText();\n\t\t\t\t\t\tinitialDescription = channelDescription.getText();\n\t\t\t\t\t\tcreateOrUpdateButton.setDisable(true);\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t\tcreateOrUpdateButton.setText(bundle.getString(\"update\"));\n\t\t\tcreateOrUpdateButton.setOnAction(_ -> {\n\t\t\t\tsetWaiting(true);\n\t\t\t\tchannelClient.updateChannelGroup(channelId,\n\t\t\t\t\t\t\t\tchannelName.getText(),\n\t\t\t\t\t\t\t\tchannelDescription.getText(),\n\t\t\t\t\t\t\t\tchannelLogo.getFile(),\n\t\t\t\t\t\t\t\t!Strings.CS.equals(initialUrl, channelLogo.getUrl()))\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(channelName)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t\t\t.subscribe();\n\t\t\t});\n\t\t}\n\t\telse\n\t\t{\n\t\t\tcreateOrUpdateButton.setOnAction(_ -> {\n\t\t\t\tsetWaiting(true);\n\t\t\t\tchannelClient.createChannelGroup(channelName.getText(),\n\t\t\t\t\t\t\t\tchannelDescription.getText(),\n\t\t\t\t\t\t\t\tchannelLogo.getFile())\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(channelName)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t\t\t.subscribe();\n\t\t\t});\n\t\t}\n\t}\n\n\tprivate void setWaiting(boolean waiting)\n\t{\n\t\tchannelName.setDisable(waiting);\n\t\tchannelDescription.setDisable(waiting);\n\t\tchannelLogo.setDisable(waiting);\n\t\tcreateOrUpdateButton.setDisable(waiting);\n\t\tcancelButton.setDisable(waiting);\n\t\tUiUtils.setPresent(progressBar, waiting);\n\t}\n\n\tprivate void checkCreatableOrUpdatable()\n\t{\n\t\tcreateOrUpdateButton.setDisable((channelId == 0L && channelName.getText().isBlank()) ||\n\t\t\t\t(channelId == 0L && channelDescription.getText().isBlank()) ||\n\t\t\t\t(\n\t\t\t\t\t\tStrings.CS.equals(initialName, channelName.getText()) &&\n\t\t\t\t\t\t\t\tStrings.CS.equals(initialDescription, channelDescription.getText()) &&\n\t\t\t\t\t\t\t\tStrings.CS.equals(initialUrl, channelLogo.getUrl())\n\t\t\t\t)\n\t\t);\n\t}\n\n\tprivate void selectGroupImage(ActionEvent event)\n\t{\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"channel.select-logo\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tChooserUtils.setSupportedLoadImageFormats(fileChooser);\n\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\tchannelLogo.setFile(selectedFile);\n\t}\n\n\tprivate void clearGroupImage(ActionEvent event)\n\t{\n\t\tchannelLogo.setImage(null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelMessageCell.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.PlaceholderImageView;\nimport io.xeres.ui.model.channel.ChannelMessage;\nimport io.xeres.ui.support.util.DateUtils;\nimport javafx.css.PseudoClass;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Node;\nimport javafx.scene.control.Label;\nimport javafx.scene.layout.HBox;\nimport org.fxmisc.flowless.Cell;\n\nimport java.io.IOException;\n\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\n\nclass ChannelMessageCell implements Cell<ChannelMessage, Node>\n{\n\tprivate static final PseudoClass selectedPseudoClass = PseudoClass.getPseudoClass(\"selected\");\n\tprivate static final PseudoClass unreadPseudoClass = PseudoClass.getPseudoClass(\"unread\");\n\n\t@FXML\n\tprivate HBox groupView;\n\n\t@FXML\n\tprivate Label titleLabel;\n\n\t@FXML\n\tprivate Label postInstantLabel;\n\n\t@FXML\n\tprivate PlaceholderImageView imageView;\n\n\tpublic ChannelMessageCell(ChannelMessage channelMessage, GeneralClient generalClient)\n\t{\n\t\tvar loader = new FXMLLoader(ChannelMessageCell.class.getResource(\"/view/channel/message_cell.fxml\"));\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\n\t\timageView.setLoader(url -> generalClient.getImage(url).block());\n\t\tupdateItem(channelMessage);\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn groupView;\n\t}\n\n\t@Override\n\tpublic boolean isReusable()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic void updateItem(ChannelMessage item)\n\t{\n\t\ttitleLabel.setText(item.getName());\n\t\tpostInstantLabel.setText(DateUtils.DATE_TIME_FORMAT.format(item.getPublished()));\n\t\timageView.setUrl(getImageUrl(item));\n\t\tgroupView.pseudoClassStateChanged(selectedPseudoClass, item.isSelected());\n\t\tgroupView.pseudoClassStateChanged(unreadPseudoClass, !item.isRead());\n\t}\n\n\tprivate String getImageUrl(ChannelMessage item)\n\t{\n\t\tif (item.hasImage())\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + CHANNELS_PATH + \"/messages/\" + item.getId() + \"/image\";\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelMessageRow.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.ui.model.channel.ChannelFile;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TableRow;\n\nclass ChannelMessageRow extends TableRow<ChannelFile>\n{\n\t@Override\n\tprotected void updateItem(ChannelFile item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tTooltipUtils.uninstall(this);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (StringUtils.isNotBlank(item.getPath()))\n\t\t\t{\n\t\t\t\tTooltipUtils.install(this, item.getPath());\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelMessageWindowController.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.ChannelClient;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.client.ShareClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.EditorView;\nimport io.xeres.ui.custom.ImageSelectorView;\nimport io.xeres.ui.model.channel.ChannelFile;\nimport io.xeres.ui.model.channel.ChannelFile.State;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.uri.FileUri;\nimport io.xeres.ui.support.uri.UriFactory;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.beans.binding.Bindings;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.event.ActionEvent;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.*;\nimport javafx.scene.control.cell.PropertyValueFactory;\nimport javafx.scene.input.TransferMode;\nimport javafx.stage.FileChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.stereotype.Component;\nimport reactor.core.publisher.SignalType;\n\nimport java.io.File;\nimport java.util.*;\n\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\n\n@Component\n@FxmlView(value = \"/view/channel/channel_message_view.fxml\")\npublic class ChannelMessageWindowController implements WindowController\n{\n\t@FXML\n\tprivate TextField channelName;\n\n\t@FXML\n\tprivate TextField title;\n\n\t@FXML\n\tprivate ImageSelectorView postLogo;\n\n\t@FXML\n\tprivate TabPane tabPane;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\t@FXML\n\tprivate EditorView editorView;\n\n\t@FXML\n\tprivate TableView<ChannelFile> channelFileTableView;\n\n\t@FXML\n\tprivate TableColumn<ChannelFile, String> tableName;\n\n\t@FXML\n\tprivate TableColumn<ChannelFile, Long> tableSize;\n\n\t@FXML\n\tprivate TableColumn<ChannelFile, State> tableState;\n\n\t@FXML\n\tprivate TableColumn<ChannelFile, String> tableHash;\n\n\t@FXML\n\tprivate Button send;\n\n\t@FXML\n\tprivate Button addFile;\n\n\t@FXML\n\tprivate Button removeFile;\n\n\t@FXML\n\tprivate Button pasteLink;\n\n\tprivate long channelId;\n\n\tprivate final ChannelClient channelClient;\n\tprivate final LocationClient locationClient;\n\tprivate final MarkdownService markdownService;\n\tprivate final ShareClient shareClient;\n\tprivate final ResourceBundle bundle;\n\n\tprivate final Queue<File> filesToAdd = new ArrayDeque<>();\n\n\tprivate final ObservableList<ChannelFile> files = FXCollections.observableArrayList();\n\n\tpublic ChannelMessageWindowController(ChannelClient channelClient, LocationClient locationClient, MarkdownService markdownService, ShareClient shareClient, ResourceBundle bundle)\n\t{\n\t\tthis.channelClient = channelClient;\n\t\tthis.locationClient = locationClient;\n\t\tthis.markdownService = markdownService;\n\t\tthis.shareClient = shareClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tpostLogo.setOnSelectAction(this::selectMessageImage);\n\t\tpostLogo.setOnDeleteAction(this::clearMessageImage);\n\n\t\tPlatform.runLater(() -> title.requestFocus());\n\n\t\teditorView.setInputContextMenu(locationClient);\n\t\teditorView.setMarkdownService(markdownService);\n\t\ttitle.setOnKeyTyped(_ -> checkSendable());\n\n\t\taddFile.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"file-requester.add-files\"));\n\t\t\tvar selectedFiles = fileChooser.showOpenMultipleDialog(getWindow(event));\n\t\t\tif (selectedFiles != null)\n\t\t\t{\n\t\t\t\taddFiles(selectedFiles);\n\t\t\t}\n\t\t});\n\n\t\tremoveFile.setOnAction(_ -> channelFileTableView.getItems().removeAll(channelFileTableView.getSelectionModel().getSelectedItems()));\n\t\tremoveFile.disableProperty().bind(Bindings.isEmpty(channelFileTableView.getSelectionModel().getSelectedItems()));\n\n\t\tpasteLink.setOnAction(_ -> {\n\t\t\tvar s = ClipboardUtils.getStringFromClipboard();\n\t\t\tif (StringUtils.isNotBlank(s))\n\t\t\t{\n\t\t\t\tString[] lines = s.split(\"\\\\R\");\n\t\t\t\tArrays.stream(lines).forEach(line -> {\n\t\t\t\t\tvar uri = UriFactory.createUri(line);\n\t\t\t\t\tif (uri instanceof FileUri fileUri)\n\t\t\t\t\t{\n\t\t\t\t\t\taddUri(fileUri);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(Alert.AlertType.INFORMATION, bundle.getString(\"channel.clipboard.error\"));\n\t\t\t}\n\t\t});\n\n\t\tsend.setOnAction(_ -> postMessage());\n\n\t\ttableName.setCellValueFactory(new PropertyValueFactory<>(\"name\"));\n\t\ttableSize.setCellFactory(_ -> new ChannelFileSizeCell());\n\t\ttableSize.setCellValueFactory(new PropertyValueFactory<>(\"size\"));\n\t\ttableState.setCellValueFactory(new PropertyValueFactory<>(\"state\"));\n\t\ttableHash.setCellValueFactory(new PropertyValueFactory<>(\"hash\"));\n\t\tchannelFileTableView.setRowFactory(_ -> new ChannelMessageRow());\n\n\t\tchannelFileTableView.setOnDragOver(event -> {\n\t\t\tif (event.getDragboard().hasFiles())\n\t\t\t{\n\t\t\t\tevent.acceptTransferModes(TransferMode.COPY_OR_MOVE);\n\t\t\t}\n\t\t\tevent.consume();\n\t\t});\n\t\tchannelFileTableView.setOnDragDropped(event -> {\n\t\t\tvar droppedFiles = event.getDragboard().getFiles();\n\t\t\taddFiles(droppedFiles);\n\t\t\tevent.setDropCompleted(true);\n\t\t\tevent.consume();\n\t\t});\n\t\tchannelFileTableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);\n\t\tchannelFileTableView.setItems(files);\n\t}\n\n\tprivate void addFiles(List<File> files)\n\t{\n\t\tfilesToAdd.addAll(CollectionUtils.emptyIfNull(files));\n\t\taddNextFile();\n\t}\n\n\tprivate void addUri(FileUri fileUri)\n\t{\n\t\tvar channelFile = new ChannelFile(fileUri.name(), null, State.DONE, fileUri.size(), fileUri.hash().toString());\n\t\tif (files.contains(channelFile))\n\t\t{\n\t\t\treturn; // Already present\n\t\t}\n\t\tfiles.add(channelFile);\n\t}\n\n\tprivate void addNextFile()\n\t{\n\t\tvar file = filesToAdd.poll();\n\t\tif (file != null)\n\t\t{\n\t\t\tvar channelFile = new ChannelFile(file.getName(), file.getPath(), State.HASHING, file.length(), null);\n\t\t\tif (files.contains(channelFile))\n\t\t\t{\n\t\t\t\treturn; // Already present\n\t\t\t}\n\t\t\tfiles.add(channelFile);\n\n\t\t\tshareClient.createTemporaryShare(file.getAbsolutePath())\n\t\t\t\t\t.doOnSuccess(result -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert result != null;\n\t\t\t\t\t\tchannelFile.setHash(result.hash());\n\t\t\t\t\t\tchannelFile.setState(State.DONE);\n\t\t\t\t\t}))\n\t\t\t\t\t.doFinally(signalType -> {\n\t\t\t\t\t\tif (signalType != SignalType.CANCEL)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPlatform.runLater(this::addNextFile);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(title);\n\t\tif (userData == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing channel id\");\n\t\t}\n\n\t\tchannelId = (long) userData;\n\n\t\tchannelClient.getChannelGroupById(channelId)\n\t\t\t\t.doOnSuccess(channelGroup -> Platform.runLater(() -> {\n\t\t\t\t\tassert channelGroup != null;\n\t\t\t\t\tchannelName.setText(channelGroup.getName());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\t// Prevent the message from being discarded by mistake\n\t\tUiUtils.getWindow(send).setOnCloseRequest(event -> {\n\t\t\tif (!title.getText().isBlank() || editorView.isModified() || !postLogo.isEmpty()) // XXX: add file list condition\n\t\t\t{\n\t\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"channel.editor.cancel\"), () -> UiUtils.getWindow(send).hide());\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void checkSendable()\n\t{\n\t\tsend.setDisable(StringUtils.isBlank(title.getText())); // XXX: more?\n\t}\n\n\tprivate void setWaiting(boolean waiting)\n\t{\n\t\ttabPane.setDisable(waiting);\n\t\tsend.setDisable(waiting);\n\t\tUiUtils.setPresent(progressBar, waiting);\n\t}\n\n\tprivate void postMessage()\n\t{\n\t\tsetWaiting(true);\n\t\tchannelClient.createChannelMessage(channelId, title.getText(), editorView.getText(), postLogo.getFile(), files, 0L)\n\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(send)))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void selectMessageImage(ActionEvent event)\n\t{\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"channel.select-image\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tChooserUtils.setSupportedLoadImageFormats(fileChooser);\n\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\tpostLogo.setFile(selectedFile);\n\t}\n\n\tprivate void clearMessageImage(ActionEvent event)\n\t{\n\t\tpostLogo.setImage(null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/channel/ChannelViewController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.channel;\n\nimport io.xeres.common.gxs.GxsGroupConstants;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.rest.notification.channel.AddOrUpdateChannelGroups;\nimport io.xeres.common.rest.notification.channel.AddOrUpdateChannelMessages;\nimport io.xeres.common.rest.notification.channel.SetChannelGroupMessagesReadState;\nimport io.xeres.common.rest.notification.channel.SetChannelMessageReadState;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.ChannelClient;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.common.GxsGroupTreeTableAction;\nimport io.xeres.ui.controller.common.GxsGroupTreeTableView;\nimport io.xeres.ui.custom.InfoView;\nimport io.xeres.ui.custom.ProgressPane;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.event.UnreadEvent;\nimport io.xeres.ui.model.channel.ChannelFile;\nimport io.xeres.ui.model.channel.ChannelGroup;\nimport io.xeres.ui.model.channel.ChannelMapper;\nimport io.xeres.ui.model.channel.ChannelMessage;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.loader.OnDemandLoader;\nimport io.xeres.ui.support.loader.OnDemandLoaderAction;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.unread.UnreadService;\nimport io.xeres.ui.support.uri.ChannelUri;\nimport io.xeres.ui.support.uri.FileUriFactory;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.fxml.FXML;\nimport javafx.scene.Node;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.SplitPane;\nimport javafx.scene.layout.Priority;\nimport javafx.scene.layout.VBox;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.lang3.StringUtils;\nimport org.fxmisc.flowless.VirtualFlow;\nimport org.fxmisc.flowless.VirtualizedScrollPane;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.common.rest.PathConfig.CHANNELS_PATH;\nimport static io.xeres.ui.support.preference.PreferenceUtils.CHANNELS;\nimport static io.xeres.ui.support.util.DateUtils.DATE_TIME_PRECISE_FORMAT;\nimport static javafx.scene.control.Alert.AlertType.WARNING;\n\n@Component\n@FxmlView(value = \"/view/channel/channel_view.fxml\")\npublic class ChannelViewController implements Controller, GxsGroupTreeTableAction<ChannelGroup>, OnDemandLoaderAction<ChannelGroup>\n{\n\t@FXML\n\tprivate GxsGroupTreeTableView<ChannelGroup> channelTree;\n\n\t@FXML\n\tprivate SplitPane splitPaneVertical;\n\n\t@FXML\n\tprivate SplitPane splitPaneHorizontal;\n\n\t@FXML\n\tprivate Button createChannel;\n\n\t@FXML\n\tprivate Button newPost;\n\n\t@FXML\n\tprivate ProgressPane channelMessagesProgress;\n\n\t@FXML\n\tprivate InfoView infoView;\n\n\tprivate final ObservableList<ChannelMessage> messages = FXCollections.observableArrayList();\n\n\tprivate OnDemandLoader<ChannelGroup, ChannelMessage> onDemandLoader;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate final ChannelClient channelClient;\n\tprivate final NotificationClient notificationClient;\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\tprivate final UnreadService unreadService;\n\tprivate final WindowManager windowManager;\n\tprivate final MarkdownService markdownService;\n\n\tprivate Disposable notificationDisposable;\n\n\tprivate ChannelMessage selectedChannelMessage;\n\n\tprivate UrlToOpen urlToOpen;\n\n\tpublic ChannelViewController(ResourceBundle bundle, ChannelClient channelClient, NotificationClient notificationClient, GeneralClient generalClient, ImageCache imageCache, UnreadService unreadService, WindowManager windowManager, MarkdownService markdownService)\n\t{\n\t\tthis.channelClient = channelClient;\n\t\tthis.bundle = bundle;\n\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tthis.unreadService = unreadService;\n\t\tthis.windowManager = windowManager;\n\t\tthis.markdownService = markdownService;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tchannelTree.initialize(CHANNELS,\n\t\t\t\tchannelClient,\n\t\t\t\tChannelGroup::new,\n\t\t\t\t() -> new ChannelGroupCell(generalClient, imageCache),\n\t\t\t\tthis);\n\n\t\tchannelTree.unreadProperty().addListener((_, _, newValue) -> unreadService.sendUnreadEvent(UnreadEvent.Element.CHANNEL, newValue));\n\n\t\t// VirtualizedScrollPane doesn't work from FXML so we add it manually\n\t\tVirtualizedScrollPane<VirtualFlow<ChannelMessage, ChannelMessageCell>> messagesView = new VirtualizedScrollPane<>(VirtualFlow.createVertical(messages, channelMessage -> new ChannelMessageCell(channelMessage, generalClient)));\n\t\tVBox.setVgrow(messagesView, Priority.ALWAYS);\n\t\tchannelMessagesProgress.getChildren().add(messagesView);\n\n\t\tonDemandLoader = new OnDemandLoader<>(messagesView, messages, channelClient, this);\n\n\t\tcreateChannel.setOnAction(_ -> windowManager.openChannelCreation(0L));\n\n\t\tnewPost.setOnAction(_ -> newChannelPost());\n\n\t\tinfoView.setLoader(url -> generalClient.getImage(url).block());\n\n\t\tmessagesView.setOnMouseClicked(event -> {\n\t\t\tvar hit = messagesView.getContent().hit(event.getX(), event.getY());\n\t\t\tif (hit.isCellHit())\n\t\t\t{\n\t\t\t\tchangeSelectedChannelMessage(hit.getCellIndex());\n\t\t\t}\n\t\t});\n\n\t\tsetupChannelNotifications();\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvent(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof ChannelUri channelUri)\n\t\t{\n\t\t\tif (!channelTree.openUrl(channelUri.gxsId(), channelUri.msgId()))\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(WARNING, bundle.getString(\"channel.view.group.not-found\"));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void changeSelectedChannelMessage(int index)\n\t{\n\t\tif (index >= 0)\n\t\t{\n\t\t\tvar channelMessage = messages.get(index);\n\t\t\tif (Objects.equals(selectedChannelMessage, channelMessage))\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tclearSelected();\n\t\t\tselectedChannelMessage = channelMessage;\n\t\t\tchannelMessage.setSelected(true);\n\t\t\tmessages.set(index, channelMessage);\n\n\t\t\tchannelClient.getChannelMessage(channelMessage.getId())\n\t\t\t\t\t.doOnSuccess(message -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert message != null;\n\t\t\t\t\t\tsetCommonMessageAttributes(message);\n\t\t\t\t\t\t// XXX: multiple versions?\n\t\t\t\t\t\tif (!message.isRead())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tchannelClient.setChannelMessageReadState(message.getId(), true)\n\t\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t\t}\n\t\t\t\t\t}))\n\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tprivate void setCommonMessageAttributes(ChannelMessage message)\n\t{\n\t\tvar header = createContent(\"## \" + message.getName() +\n\t\t\t\t\"\\n\\n#### \" + DATE_TIME_PRECISE_FORMAT.format(message.getPublished()));\n\n\t\tvar body = createContent(StringUtils.defaultString(message.getContent()) + \"\\n\\n\" +\n\t\t\t\tgetFiles(message.getFiles()));\n\n\t\tif (message.hasImage())\n\t\t{\n\t\t\tinfoView.setInfo(header, body, RemoteUtils.getControlUrl() + CHANNELS_PATH + \"/messages/\" + message.getId() + \"/image\", message.getImageWidth(), message.getImageHeight());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tinfoView.setInfo(header, body);\n\t\t}\n\t}\n\n\tprivate String getFiles(List<ChannelFile> files)\n\t{\n\t\tvar result = files.isEmpty() ? \"\" : \"\\n\\n### %s\\n\\n- \".formatted(bundle.getString(\"channel.files\"));\n\t\tresult += files.stream()\n\t\t\t\t.map(file -> FileUriFactory.generateMarkdown(file.getName(), file.getSize(), Sha1Sum.fromString(file.getHash())))\n\t\t\t\t.collect(Collectors.joining(\"\\n- \"));\n\t\treturn result;\n\t}\n\n\tprivate void clearSelected()\n\t{\n\t\tif (selectedChannelMessage != null)\n\t\t{\n\t\t\tselectedChannelMessage.setSelected(false);\n\t\t\tmessages.set(messages.indexOf(selectedChannelMessage), selectedChannelMessage);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onSubscribeToGroup(ChannelGroup group)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void onUnsubscribeFromGroup(ChannelGroup group)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void onCopyGroupLink(ChannelGroup group)\n\t{\n\t\tvar channelUri = new ChannelUri(group.getName(), group.getGxsId(), null);\n\t\tClipboardUtils.copyTextToClipboard(channelUri.toUriString());\n\t}\n\n\t@Override\n\tpublic void onOpenUrl(GxsId gxsId, MsgId msgId)\n\t{\n\t\tif (gxsId.equals(channelTree.getSelectedGroupGxsId()))\n\t\t{\n\t\t\tselectMessage(msgId);\n\t\t}\n\t\telse\n\t\t{\n\t\t\turlToOpen = new UrlToOpen(gxsId, msgId);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onMessagesLoaded(ChannelGroup group)\n\t{\n\t\tchannelMessagesState(false);\n\t\tif (urlToOpen != null)\n\t\t{\n\t\t\tif (group.getGxsId().equals(urlToOpen.gxsId()))\n\t\t\t{\n\t\t\t\tselectMessage(urlToOpen.msgId());\n\t\t\t\turlToOpen = null;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void selectMessage(MsgId msgId)\n\t{\n\t\tfor (var i = 0; i < messages.size(); i++)\n\t\t{\n\t\t\tvar message = messages.get(i);\n\t\t\tif (message.getMsgId().equals(msgId))\n\t\t\t{\n\t\t\t\tchangeSelectedChannelMessage(i);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onSelectSubscribedGroup(ChannelGroup group)\n\t{\n\t\tselectedChannelMessage = null;\n\t\tchannelMessagesState(true);\n\t\tonDemandLoader.changeSelection(group);\n\t\tnewPost.setDisable(group.isExternal());\n\t\tshowGroupInfo(group);\n\t}\n\n\t@Override\n\tpublic void onSelectUnsubscribedGroup(ChannelGroup group)\n\t{\n\t\tselectedChannelMessage = null;\n\t\tonDemandLoader.changeSelection(group);\n\t\tnewPost.setDisable(true);\n\t\tshowGroupInfo(group);\n\t}\n\n\t@Override\n\tpublic void onUnselectGroup()\n\t{\n\t\tselectedChannelMessage = null;\n\t\tonDemandLoader.changeSelection(null);\n\t\tnewPost.setDisable(true);\n\t\tshowGroupInfo(null);\n\t}\n\n\t@Override\n\tpublic void onEditGroup(ChannelGroup group)\n\t{\n\t\twindowManager.openChannelCreation(group.getId());\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (notificationDisposable != null && !notificationDisposable.isDisposed())\n\t\t{\n\t\t\tnotificationDisposable.dispose();\n\t\t}\n\t}\n\n\tprivate void setupChannelNotifications()\n\t{\n\t\tnotificationDisposable = notificationClient.getChannelNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tswitch (sse.data())\n\t\t\t\t\t{\n\t\t\t\t\t\tcase AddOrUpdateChannelGroups action ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\taction.channelGroups().forEach(channelGroupItem -> imageCache.evictImage(RemoteUtils.getControlUrl() + CHANNELS_PATH + \"/groups/\" + channelGroupItem.id() + \"/image\"));\n\n\t\t\t\t\t\t\tchannelTree.addGroups(action.channelGroups().stream()\n\t\t\t\t\t\t\t\t\t.map(ChannelMapper::fromDTO)\n\t\t\t\t\t\t\t\t\t.toList());\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase AddOrUpdateChannelMessages action -> addChannelMessages(action.channelMessages().stream()\n\t\t\t\t\t\t\t\t.map(ChannelMapper::fromDTO)\n\t\t\t\t\t\t\t\t.toList());\n\t\t\t\t\t\tcase SetChannelMessageReadState action -> setMessageReadState(action.groupId(), action.messageId(), action.read());\n\t\t\t\t\t\tcase SetChannelGroupMessagesReadState action -> setGroupMessagesReadState(action.groupId(), action.read());\n\t\t\t\t\t\tcase null -> throw new IllegalArgumentException(\"Channel notifications have not been set\");\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\tonDemandLoader.setMessageReadState(groupId, messageId, read);\n\t\tchannelTree.setUnreadCount(groupId, read);\n\t}\n\n\tprivate void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tonDemandLoader.setGroupMessagesReadState(groupId, read);\n\t\tchannelTree.refreshUnreadCount(groupId);\n\t}\n\n\tprivate void newChannelPost()\n\t{\n\t\twindowManager.openChannelMessage(channelTree.getSelectedGroupId());\n\t}\n\n\tprivate void addChannelMessages(List<ChannelMessage> channelMessages)\n\t{\n\t\tSet<GxsId> channelsToUpdate = new HashSet<>();\n\n\t\tfor (ChannelMessage channelMessage : channelMessages)\n\t\t{\n\t\t\tonDemandLoader.insertMessage(channelMessage);\n\t\t\tchannelsToUpdate.add(channelMessage.getGxsId());\n\t\t}\n\t\tchannelTree.refreshUnreadCount(channelsToUpdate);\n\t}\n\n\tprivate List<Node> createContent(String input)\n\t{\n\t\treturn markdownService.parse(input, EnumSet.noneOf(MarkdownService.Rendering.class)).stream()\n\t\t\t\t.map(Content::getNode).toList();\n\t}\n\n\tprivate void channelMessagesState(boolean loading)\n\t{\n\t\tPlatform.runLater(() -> channelMessagesProgress.showProgress(loading));\n\t}\n\n\tprivate void showGroupInfo(ChannelGroup group)\n\t{\n\t\tif (group != null && group.isReal())\n\t\t{\n\t\t\tvar header = createContent(\"\"\"\n\t\t\t\t\t## %s\n\t\t\t\t\t\n\t\t\t\t\t%s: %s\\\\\n\t\t\t\t\t%s: %s\n\t\t\t\t\t\"\"\".formatted(\n\t\t\t\t\tgroup.getName(),\n\t\t\t\t\tbundle.getString(\"posts-at-remote-nodes\"),\n\t\t\t\t\tgroup.getVisibleMessageCount(),\n\t\t\t\t\tbundle.getString(\"last-activity\"),\n\t\t\t\t\tDateUtils.formatDateTime(group.getLastActivity(), bundle.getString(\"unknown-lc\"))));\n\n\t\t\tvar body = createContent(group.getDescription());\n\n\t\t\tif (group.hasImage())\n\t\t\t{\n\t\t\t\tinfoView.setInfo(header, body, RemoteUtils.getControlUrl() + CHANNELS_PATH + \"/groups/\" + group.getId() + \"/image\", GxsGroupConstants.IMAGE_SIDE_SIZE, GxsGroupConstants.IMAGE_SIDE_SIZE);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tinfoView.setInfo(header, body);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tinfoView.setInfo(null, null);\n\t\t}\n\t\tchannelMessagesState(false);\n\t}\n\n\trecord UrlToOpen(GxsId gxsId, MsgId msgId)\n\t{\n\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.ui.support.chat.ChatLine;\nimport io.xeres.ui.support.chat.ColorGenerator;\nimport io.xeres.ui.support.contentline.Content;\nimport javafx.css.PseudoClass;\nimport javafx.scene.control.Label;\nimport javafx.scene.shape.Path;\nimport javafx.scene.text.TextFlow;\nimport org.fxmisc.flowless.Cell;\n\nimport java.util.List;\n\nimport static io.xeres.ui.support.util.DateUtils.TIME_FORMAT;\n\nclass ChatListCell implements Cell<ChatLine, TextFlow>\n{\n\tprivate static final PseudoClass passivePseudoClass = PseudoClass.getPseudoClass(\"passive\");\n\tprivate static final PseudoClass quotedPseudoClass = PseudoClass.getPseudoClass(\"quoted\");\n\n\tprivate static final List<String> allColors = ColorGenerator.getAllColors();\n\n\tprivate final TextFlow content;\n\tprivate final Label time;\n\tprivate final Label action;\n\tprivate boolean isRich;\n\n\tpublic ChatListCell(ChatLine line)\n\t{\n\t\tcontent = new TextFlow();\n\t\tcontent.getStyleClass().add(\"list-cell\");\n\n\t\ttime = new Label();\n\t\ttime.getStyleClass().add(\"time\");\n\n\t\taction = new Label();\n\t\taction.getStyleClass().add(\"action\");\n\n\t\tcontent.getChildren().addAll(time, action);\n\n\t\tupdateItem(line);\n\t}\n\n\t@Override\n\tpublic TextFlow getNode()\n\t{\n\t\treturn content;\n\t}\n\n\t@Override\n\tpublic boolean isReusable()\n\t{\n\t\treturn !isRich && !(content.getChildren().getLast() instanceof Path); // Do not reuse rich content AND selected content\n\t}\n\n\t@Override\n\tpublic void reset()\n\t{\n\t\tif (isReusable())\n\t\t{\n\t\t\tif (content.getChildren().size() > 2)\n\t\t\t{\n\t\t\t\tcontent.getChildren().remove(2); // keep time and action only\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void updateItem(ChatLine line)\n\t{\n\t\tisRich = line.isRich();\n\n\t\ttime.setText(TIME_FORMAT.format(line.getInstant()));\n\n\t\taction.setText(line.getAction());\n\t\taction.getStyleClass().removeAll(allColors);\n\t\tvar nicknameColor = line.getNicknameColor();\n\t\tif (nicknameColor != null)\n\t\t{\n\t\t\taction.getStyleClass().add(nicknameColor);\n\t\t}\n\n\t\tvar nodes = line.getChatContents().stream()\n\t\t\t\t.map(Content::getNode)\n\t\t\t\t.toList();\n\n\t\tcontent.pseudoClassStateChanged(passivePseudoClass, !line.isActiveAction());\n\t\tcontent.pseudoClassStateChanged(quotedPseudoClass, line.isQuote());\n\n\t\tcontent.getChildren().addAll(nodes);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.ui.support.chat.ChatLine;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.util.TextFlowUtils;\nimport io.xeres.ui.support.util.TextFlowUtils.Options;\nimport io.xeres.ui.support.util.TextSelectRange;\nimport javafx.scene.Cursor;\nimport javafx.scene.Node;\nimport javafx.scene.input.MouseEvent;\nimport javafx.scene.text.HitInfo;\nimport javafx.scene.text.TextFlow;\nimport org.fxmisc.flowless.VirtualFlow;\nimport org.fxmisc.flowless.VirtualFlowHit;\n\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nclass ChatListDragSelection\n{\n\tprivate final Node focusNode;\n\n\tprivate enum SelectionMode\n\t{\n\t\tTEXT,\n\t\tACTION_AND_TEXT,\n\t\tTIME_ACTION_AND_TEXT\n\t}\n\n\tprivate enum Direction\n\t{\n\t\tSAME,\n\t\tDOWN,\n\t\tUP\n\t}\n\n\tprivate HitInfo firstHitInfo;\n\tprivate int startCellIndex;\n\tprivate int lastCellIndex;\n\n\tprivate SelectionMode selectionMode;\n\n\tprivate TextSelectRange textSelectRange; // This is used for one line of text\n\n\tprivate Direction direction = Direction.SAME;\n\n\tprivate final List<TextFlow> textFlows = new LinkedList<>();\n\n\tpublic ChatListDragSelection(Node focusNode)\n\t{\n\t\tthis.focusNode = focusNode;\n\t}\n\n\tpublic void press(MouseEvent e)\n\t{\n\t\tif (e.getEventType() != MouseEvent.MOUSE_PRESSED)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Event must be a MOUSE_PRESSED event\");\n\t\t}\n\n\t\tclearSelection();\n\n\t\tvar virtualFlow = getVirtualFlow(e);\n\t\tvirtualFlow.setCursor(Cursor.TEXT);\n\t\tvar hitResult = virtualFlow.hit(e.getX(), e.getY());\n\t\tif (hitResult.isCellHit())\n\t\t{\n\t\t\tvar textFlow = hitResult.getCell().getNode();\n\t\t\tstartCellIndex = hitResult.getCellIndex();\n\t\t\ttextFlows.add(textFlow);\n\n\t\t\tvar hitInfo = textFlow.getHitInfo(hitResult.getCellOffset());\n\t\t\tfirstHitInfo = hitInfo;\n\n\t\t\tswitch (hitInfo.getCharIndex())\n\t\t\t{\n\t\t\t\tcase 0 -> selectionMode = SelectionMode.TIME_ACTION_AND_TEXT;\n\t\t\t\tcase 1 -> selectionMode = SelectionMode.ACTION_AND_TEXT;\n\t\t\t\tdefault -> selectionMode = SelectionMode.TEXT;\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void drag(MouseEvent e)\n\t{\n\t\tif (e.getEventType() != MouseEvent.MOUSE_DRAGGED)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Event must be a MOUSE_DRAGGED event\");\n\t\t}\n\n\t\tvar virtualFlow = getVirtualFlow(e);\n\t\tvar hitResult = virtualFlow.hit(e.getX(), e.getY());\n\t\tif (hitResult.isCellHit())\n\t\t{\n\t\t\tvar cellIndex = hitResult.getCellIndex();\n\t\t\tif (cellIndex < virtualFlow.getFirstVisibleIndex() || cellIndex > virtualFlow.getLastVisibleIndex())\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// XXX: this is not currently working (remove the above to have this be reachable)\n\t\t\tif (cellIndex <= virtualFlow.getFirstVisibleIndex())\n\t\t\t{\n\t\t\t\tvirtualFlow.showAsFirst(cellIndex);\n\t\t\t}\n\t\t\telse if (cellIndex >= virtualFlow.getLastVisibleIndex())\n\t\t\t{\n\t\t\t\tvirtualFlow.showAsLast(cellIndex);\n\t\t\t}\n\n\t\t\tif (!handleMultilineSelect(virtualFlow, hitResult))\n\t\t\t{\n\t\t\t\thandleSingleLineSelect(hitResult);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void release(MouseEvent e)\n\t{\n\t\tif (e.getEventType() != MouseEvent.MOUSE_RELEASED)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Event must be a MOUSE_RELEASED event\");\n\t\t}\n\n\t\tvar virtualFlow = getVirtualFlow(e);\n\t\tvirtualFlow.setCursor(Cursor.DEFAULT);\n\n\t\tif (textSelectRange == null || !textSelectRange.isSelected())\n\t\t{\n\t\t\tclearSelection();\n\t\t\ttextSelectRange = null;\n\t\t}\n\n\t\tif (focusNode != null)\n\t\t{\n\t\t\tfocusNode.requestFocus();\n\t\t}\n\t}\n\n\tpublic void copy()\n\t{\n\t\tvar text = getSelectionAsText();\n\t\tif (StringUtils.isNotBlank(text))\n\t\t{\n\t\t\tClipboardUtils.copyTextToClipboard(text);\n\t\t}\n\t}\n\n\tpublic boolean isSelected()\n\t{\n\t\treturn textFlows.size() > 1 || (textSelectRange != null && textSelectRange.isSelected());\n\t}\n\n\tprivate boolean handleMultilineSelect(VirtualFlow<ChatLine, ChatListCell> virtualFlow, VirtualFlowHit<ChatListCell> hitResult)\n\t{\n\t\tvar cellIndex = hitResult.getCellIndex();\n\t\tvar textFlow = hitResult.getCell().getNode();\n\n\t\tif (cellIndex != startCellIndex)\n\t\t{\n\t\t\tif (direction == Direction.SAME)\n\t\t\t{\n\t\t\t\t// We're switching to multiline mode.\n\t\t\t\tif (!textFlows.isEmpty())\n\t\t\t\t{\n\t\t\t\t\tvar pathElements = textFlows.getFirst().getRangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlows.getFirst()), false);\n\t\t\t\t\tTextFlowUtils.showSelection(textFlows.getFirst(), pathElements);\n\n\t\t\t\t\tdirection = cellIndex > startCellIndex ? Direction.DOWN : Direction.UP;\n\t\t\t\t\tmarkSelection(virtualFlow, startCellIndex, cellIndex);\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tmarkSelection(virtualFlow, lastCellIndex, cellIndex);\n\t\t\t}\n\t\t\tlastCellIndex = cellIndex;\n\t\t\treturn true;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (direction != Direction.SAME)\n\t\t\t{\n\t\t\t\t// We're coming back to single line mode.\n\t\t\t\tclearSelection();\n\t\t\t\tdirection = Direction.SAME;\n\t\t\t\ttextFlows.add(textFlow);\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate void markSelection(VirtualFlow<ChatLine, ChatListCell> virtualFlow, int fromCell, int toCell)\n\t{\n\t\tswitch (direction)\n\t\t{\n\t\t\tcase UP ->\n\t\t\t{\n\t\t\t\tif (fromCell > toCell) // Going up (mark more)\n\t\t\t\t{\n\t\t\t\t\tfor (int i = fromCell; i >= toCell; i--)\n\t\t\t\t\t{\n\t\t\t\t\t\taddVisibleSelection(virtualFlow.getCell(i).getNode());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse if (fromCell < toCell) // Going down (unwind)\n\t\t\t\t{\n\t\t\t\t\tfor (int i = fromCell; i < toCell; i++)\n\t\t\t\t\t{\n\t\t\t\t\t\tremoveVisibleSelection(virtualFlow.getCell(i).getNode());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase DOWN ->\n\t\t\t{\n\t\t\t\tif (fromCell < toCell) // Going down (mark more)\n\t\t\t\t{\n\t\t\t\t\tfor (int i = fromCell; i <= toCell; i++)\n\t\t\t\t\t{\n\t\t\t\t\t\taddVisibleSelection(virtualFlow.getCell(i).getNode());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse if (fromCell > toCell) // Going up (unwind)\n\t\t\t\t{\n\t\t\t\t\tfor (int i = fromCell; i > toCell; i--)\n\t\t\t\t\t{\n\t\t\t\t\t\tremoveVisibleSelection(virtualFlow.getCell(i).getNode());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase null, default -> throw new IllegalArgumentException(\"Wrong direction: \" + direction);\n\t\t}\n\t}\n\n\tprivate int getOffsetFromSelectionMode()\n\t{\n\t\treturn switch (selectionMode)\n\t\t{\n\t\t\tcase TIME_ACTION_AND_TEXT -> 0;\n\t\t\tcase ACTION_AND_TEXT -> 1;\n\t\t\tcase TEXT -> 2;\n\t\t};\n\t}\n\n\tprivate void handleSingleLineSelect(VirtualFlowHit<ChatListCell> hitResult)\n\t{\n\t\tvar textFlow = hitResult.getCell().getNode();\n\n\t\ttextSelectRange = new TextSelectRange(firstHitInfo, textFlow.getHitInfo(hitResult.getCellOffset()));\n\n\t\tif (textSelectRange.isSelected())\n\t\t{\n\t\t\tvar pathElements = textFlow.getRangeShape(textSelectRange.getStart(), textSelectRange.getEnd() + 1, false);\n\t\t\tTextFlowUtils.showSelection(textFlow, pathElements);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tTextFlowUtils.hideSelection(textFlow);\n\t\t}\n\t}\n\n\tprivate void addVisibleSelection(TextFlow textFlow)\n\t{\n\t\tTextFlowUtils.showSelection(textFlow, textFlow.getRangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlow), false));\n\t\tif (textFlows.getLast() != textFlow)\n\t\t{\n\t\t\ttextFlows.add(textFlow);\n\t\t}\n\t}\n\n\tprivate void removeVisibleSelection(TextFlow textFlow)\n\t{\n\t\tTextFlowUtils.hideSelection(textFlow);\n\t\ttextFlows.remove(textFlow);\n\t}\n\n\tprivate void clearSelection()\n\t{\n\t\twhile (!textFlows.isEmpty())\n\t\t{\n\t\t\tvar textFlow = textFlows.getLast();\n\t\t\tremoveVisibleSelection(textFlow);\n\t\t}\n\t\ttextSelectRange = null;\n\t}\n\n\tprivate String getSelectionAsText()\n\t{\n\t\tif (textFlows.isEmpty())\n\t\t{\n\t\t\treturn \"\";\n\t\t}\n\n\t\tif (textFlows.size() == 1)\n\t\t{\n\t\t\t// Single line selection\n\t\t\tvar textFlow = textFlows.getFirst();\n\n\t\t\tassert textFlow.getChildren().size() >= 3;\n\n\t\t\treturn TextFlowUtils.getTextFlowAsText(textFlow, textSelectRange.getStart(), textSelectRange.getEnd() + 1, Options.SPACED_PREFIXES);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (direction == Direction.UP)\n\t\t\t{\n\t\t\t\treturn textFlows.reversed().stream()\n\t\t\t\t\t\t.map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode(), Options.SPACED_PREFIXES))\n\t\t\t\t\t\t.collect(Collectors.joining(\"\\n\"));\n\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn textFlows.stream()\n\t\t\t\t\t\t.map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode(), Options.SPACED_PREFIXES))\n\t\t\t\t\t\t.collect(Collectors.joining(\"\\n\"));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate VirtualFlow<ChatLine, ChatListCell> getVirtualFlow(MouseEvent e)\n\t{\n\t\t//noinspection unchecked\n\t\treturn (VirtualFlow<ChatLine, ChatListCell>) e.getSource();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.message.chat.ChatRoomMessage;\nimport io.xeres.common.message.chat.ChatRoomTimeoutEvent;\nimport io.xeres.common.message.chat.ChatRoomUserEvent;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.client.preview.PreviewClient;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.support.chat.ChatAction;\nimport io.xeres.ui.support.chat.ChatLine;\nimport io.xeres.ui.support.chat.ChatParser;\nimport io.xeres.ui.support.chat.NicknameCompleter;\nimport io.xeres.ui.support.contentline.ContentImage;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.contentline.ContentUriPreview;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.markdown.MarkdownService.Rendering;\nimport io.xeres.ui.support.markdown.UriAction;\nimport io.xeres.ui.support.uri.ExternalUri;\nimport io.xeres.ui.support.uri.IdentityUri;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.scene.Node;\nimport javafx.scene.control.ListView;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.image.Image;\nimport javafx.scene.input.MouseEvent;\nimport javafx.scene.layout.AnchorPane;\nimport javafx.scene.layout.Priority;\nimport javafx.scene.layout.VBox;\nimport org.fxmisc.flowless.VirtualFlow;\nimport org.fxmisc.flowless.VirtualizedScrollPane;\nimport org.jsoup.Jsoup;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignA;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignM;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\n\nimport static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID;\nimport static io.xeres.ui.support.chat.ChatAction.Type.*;\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\nimport static org.apache.commons.lang3.StringUtils.isNotEmpty;\n\npublic class ChatListView implements NicknameCompleter.UsernameFinder\n{\n\tprivate static final int SCROLL_BACK_MAX_LINES = 1000;\n\tprivate static final int SCROLL_BACK_CLEANUP_THRESHOLD = 100;\n\n\tprivate static final Duration PREVIEW_WINDOW = Duration.ofSeconds(30);\n\n\tprivate static final String INFO_MENU_ID = \"info\";\n\tprivate static final String CHAT_MENU_ID = \"chat\";\n\n\tprivate final ObservableList<ChatLine> messages = FXCollections.observableArrayList();\n\tprivate final Map<GxsId, ChatRoomUser> userMap = new HashMap<>();\n\tprivate final ObservableList<ChatRoomUser> users = FXCollections.observableArrayList();\n\n\tprivate String nickname;\n\tprivate final long id;\n\n\tprivate final AnchorPane anchorPane;\n\tprivate final VirtualizedScrollPane<VirtualFlow<ChatLine, ChatListCell>> chatView;\n\tprivate final ListView<ChatRoomUser> userListView;\n\tprivate final MarkdownService markdownService;\n\tprivate final UriAction uriAction;\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\tprivate final ResourceBundle bundle;\n\tprivate final WindowManager windowManager;\n\tprivate PreviewClient previewClient;\n\n\tprivate final ChatListDragSelection dragSelection;\n\n\tprivate final ChatListViewContextMenu contextMenu;\n\n\tpublic enum AddUserOrigin\n\t{\n\t\tJOIN,\n\t\tKEEP_ALIVE\n\t}\n\n\tpublic ChatListView(String nickname, long id, MarkdownService markdownService, UriAction uriAction, GeneralClient generalClient, ImageCache imageCache, WindowManager windowManager, Node focusNode)\n\t{\n\t\tthis.nickname = nickname;\n\t\tthis.id = id;\n\t\tthis.markdownService = markdownService;\n\t\tthis.uriAction = uriAction;\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tthis.windowManager = windowManager;\n\t\tbundle = I18nUtils.getBundle();\n\t\tanchorPane = new AnchorPane();\n\n\t\tdragSelection = new ChatListDragSelection(focusNode);\n\n\t\tchatView = createChatView(dragSelection);\n\t\taddToAnchorPane(chatView, anchorPane);\n\n\t\tuserListView = createUserListView();\n\n\t\tcontextMenu = new ChatListViewContextMenu();\n\n\t\t// Make sure we stick to the bottom even when we resize the chatview (user typing multiple lines, other user offline, ...)\n\t\tanchorPane.heightProperty().addListener((_, _, _) -> jumpToBottom(false));\n\t}\n\n\t/**\n\t * Enables previews. Only use it for trustable channels (not public chats, etc...).\n\t *\n\t * @param previewClient the preview client\n\t */\n\tpublic void setPreviewClient(PreviewClient previewClient)\n\t{\n\t\tthis.previewClient = previewClient;\n\t}\n\n\tpublic void installClearHistoryContextMenu(Runnable action)\n\t{\n\t\tcontextMenu.installClearHistoryMenu(_ -> UiUtils.showAlertConfirm(bundle.getString(\"chat.room.clear-history\"), () -> {\n\t\t\taction.run();\n\t\t\tmessages.clear();\n\t\t}));\n\t}\n\n\tprivate VirtualizedScrollPane<VirtualFlow<ChatLine, ChatListCell>> createChatView(ChatListDragSelection selection)\n\t{\n\t\tfinal var view = VirtualFlow.createVertical(messages, ChatListCell::new, VirtualFlow.Gravity.REAR);\n\t\tview.setFocusTraversable(false);\n\t\tview.getStyleClass().add(\"chat-list\");\n\t\tview.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> {\n\t\t\tcontextMenu.hide();\n\t\t\tif (!e.isSecondaryButtonDown())\n\t\t\t{\n\t\t\t\tselection.press(e);\n\t\t\t\tcontextMenu.removeSelectionMenu();\n\t\t\t}\n\t\t});\n\t\tview.addEventFilter(MouseEvent.MOUSE_DRAGGED, selection::drag);\n\t\tview.addEventFilter(MouseEvent.MOUSE_RELEASED, selection::release);\n\t\tview.setOnContextMenuRequested(event -> {\n\t\t\tif (selection.isSelected())\n\t\t\t{\n\t\t\t\tcontextMenu.installSelectionMenu(_ -> selection.copy());\n\t\t\t}\n\t\t\tcontextMenu.show(view, event.getScreenX(), event.getScreenY());\n\t\t\tevent.consume();\n\t\t});\n\n\t\treturn new VirtualizedScrollPane<>(view);\n\t}\n\n\tprivate ListView<ChatRoomUser> createUserListView()\n\t{\n\t\tfinal ListView<ChatRoomUser> view;\n\t\tview = new ListView<>();\n\t\tview.getStyleClass().add(\"chat-user-list\");\n\t\tVBox.setVgrow(view, Priority.ALWAYS);\n\n\t\tview.setCellFactory(_ -> new ChatUserCell(generalClient, imageCache));\n\t\tview.setItems(users);\n\n\t\tcreateUsersListViewContextMenu(view);\n\t\treturn view;\n\t}\n\n\tpublic boolean copy()\n\t{\n\t\tif (dragSelection.isSelected())\n\t\t{\n\t\t\tdragSelection.copy();\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tpublic void addOwnMessage(ChatMessage chatMessage)\n\t{\n\t\taddOwnMessage(Instant.now(), chatMessage.getContent());\n\t}\n\n\tpublic void addOwnMessage(ChatRoomMessage chatRoomMessage)\n\t{\n\t\taddOwnMessage(Instant.now(), chatRoomMessage.getContent());\n\t}\n\n\tpublic void addOwnMessage(Instant when, String message)\n\t{\n\t\tvar chatAction = new ChatAction(SAY_OWN, nickname, null);\n\t\taddMessage(when, chatAction, message);\n\t\tjumpToBottom(true); // Always move to the bottom for our own message\n\t}\n\n\tpublic void addUserMessage(String from, String message)\n\t{\n\t\taddUserMessage(Instant.now(), from, message);\n\t}\n\n\tpublic void addUserMessage(Instant when, String from, String message)\n\t{\n\t\tvar chatAction = new ChatAction(SAY, from, null);\n\t\taddMessage(when, chatAction, message);\n\t}\n\n\tpublic void addUserMessage(String from, GxsId gxsId, String message)\n\t{\n\t\taddUserMessage(Instant.now(), from, gxsId, message);\n\t}\n\n\tpublic void addUserMessage(Instant when, String from, GxsId gxsId, String message)\n\t{\n\t\tvar chatAction = new ChatAction(SAY, from, gxsId);\n\t\taddMessage(when, chatAction, message);\n\t}\n\n\tprivate void addMessage(Instant time, ChatAction chatAction, String message)\n\t{\n\t\tmessage = removeEmtpyImageTag(message);\n\n\t\tvar img = Jsoup.parse(message).selectFirst(\"img\");\n\n\t\tif (img != null)\n\t\t{\n\t\t\tvar data = img.absUrl(\"src\");\n\t\t\tif (isNotEmpty(data) && data.startsWith(\"data:\")) // the core only allows 'data' already but better safe than sorry\n\t\t\t{\n\t\t\t\tvar image = new Image(data);\n\t\t\t\tif (!image.isError() && !ImageViewUtils.isExaggeratedAspectRatio(image))\n\t\t\t\t{\n\t\t\t\t\taddMessageLine(time, chatAction, image);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (ChatParser.isActionMe(message))\n\t\t\t{\n\t\t\t\tmessage = ChatParser.parseActionMe(message, chatAction.getNickname());\n\t\t\t\tchatAction.setType(ACTION);\n\t\t\t}\n\t\t\tvar contents = markdownService.parse(message, EnumSet.of(Rendering.CHAT), uriAction);\n\t\t\tvar chatLine = new ChatLine(time, chatAction, contents);\n\t\t\taddMessageLine(chatLine);\n\t\t\tif (chatAction.getType() == SAY)\n\t\t\t{\n\t\t\t\t// Important: do *NOT* perform preview for things we send ourselves. Otherwise, both us and\n\t\t\t\t// the recipient will perform a near simultaneous access, and friends could be easily correlated\n\t\t\t\t// by the target website.\n\t\t\t\tscanForPreview(time, chatLine);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void scanForPreview(Instant messageArrival, ChatLine chatLine)\n\t{\n\t\tif (previewClient == null || Duration.between(messageArrival, Instant.now()).compareTo(PREVIEW_WINDOW) > 0) // Don't preview \"old\" URLs\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tchatLine.getChatContents().stream()\n\t\t\t\t.filter(ContentUri.class::isInstance) // Only handle the first URI\n\t\t\t\t.findFirst()\n\t\t\t\t.map(content -> ((ContentUri) content).getUri())\n\t\t\t\t.ifPresent(url -> previewClient.getPreview(url)\n\t\t\t\t\t\t.doOnSuccess(preview -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert preview != null;\n\t\t\t\t\t\t\tif (!preview.hasInfo())\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tvar index = messages.indexOf(chatLine);\n\t\t\t\t\t\t\tif (index >= 0)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvar contents = new ArrayList<>(chatLine.getChatContents());\n\t\t\t\t\t\t\t\tfor (var i = 0; i < contents.size(); i++)\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tif (contents.get(i) instanceof ContentUri contentUri) // And replace back the first URI\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tcontents.set(i, new ContentUriPreview(\n\t\t\t\t\t\t\t\t\t\t\t\tnew ExternalUri(url),\n\t\t\t\t\t\t\t\t\t\t\t\tpreview.title(),\n\t\t\t\t\t\t\t\t\t\t\t\tpreview.description(),\n\t\t\t\t\t\t\t\t\t\t\t\tpreview.site(),\n\t\t\t\t\t\t\t\t\t\t\t\tpreview.thumbnailUrl(),\n\t\t\t\t\t\t\t\t\t\t\t\tpreview.thumbnailWidth(),\n\t\t\t\t\t\t\t\t\t\t\t\tpreview.thumbnailHeight(),\n\t\t\t\t\t\t\t\t\t\t\t\tthumbUrl -> previewClient.getImage(thumbUrl).block(), contentUri.getAction(),\n\t\t\t\t\t\t\t\t\t\t\t\t() -> jumpToBottom(false)));\n\t\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tvar newChatLine = chatLine.withContent(contents);\n\t\t\t\t\t\t\t\tmessages.set(index, newChatLine);\n\t\t\t\t\t\t\t\tjumpToBottom(false);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe());\n\t}\n\n\t/**\n\t * Removes the empty img tag that is added by Retroshare when sending a file URL.\n\t *\n\t * @param message the message\n\t * @return the cleaned up message\n\t */\n\tprivate static String removeEmtpyImageTag(String message)\n\t{\n\t\tif (message.startsWith(\"<img>\") && message.length() > 5)\n\t\t{\n\t\t\tmessage = message.substring(5);\n\t\t}\n\t\treturn message;\n\t}\n\n\tpublic void addUser(ChatRoomUserEvent event, AddUserOrigin addUserOrigin)\n\t{\n\t\tif (!userMap.containsKey(event.getGxsId()))\n\t\t{\n\t\t\tvar chatRoomUser = new ChatRoomUser(event.getGxsId(), event.getNickname(), event.getIdentityId());\n\t\t\tusers.add(chatRoomUser);\n\t\t\tuserMap.put(event.getGxsId(), chatRoomUser);\n\t\t\tusers.sort((o1, o2) -> o1.nickname().compareToIgnoreCase(o2.nickname()));\n\t\t\tif (addUserOrigin == AddUserOrigin.JOIN && !nickname.equals(event.getNickname()))\n\t\t\t{\n\t\t\t\taddMessageLine(new ChatAction(JOIN, event.getNickname(), event.getGxsId()));\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void removeUser(ChatRoomUserEvent event)\n\t{\n\t\tvar chatRoomUser = userMap.remove(event.getGxsId());\n\n\t\tif (chatRoomUser != null)\n\t\t{\n\t\t\tusers.remove(chatRoomUser);\n\t\t\taddMessageLine(new ChatAction(LEAVE, event.getNickname(), event.getGxsId()));\n\t\t}\n\t}\n\n\tpublic void timeoutUser(ChatRoomTimeoutEvent event)\n\t{\n\t\tvar chatRoomUser = userMap.remove(event.getGxsId());\n\n\t\tif (chatRoomUser != null)\n\t\t{\n\t\t\tusers.remove(chatRoomUser);\n\t\t\tif (!event.isSplit() && userSaidSomethingRecently(event.getGxsId()))\n\t\t\t{\n\t\t\t\t// Only display this if the user said something 5-10 minutes ago, so that we know that the conversation is \"dead\". Displaying it all the time is too verbose\n\t\t\t\taddMessageLine(new ChatAction(TIMEOUT, chatRoomUser.nickname(), event.getGxsId()));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate boolean userSaidSomethingRecently(GxsId gxsId)\n\t{\n\t\tvar now = Instant.now();\n\n\t\tfor (var i = messages.size() - 1; i >= 0; i--)\n\t\t{\n\t\t\tvar message = messages.get(i);\n\t\t\tif (message.getInstant().isBefore(now.minus(10, ChronoUnit.MINUTES)))\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (message.hasSaid(gxsId))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic String getUsername(String prefix, int index)\n\t{\n\t\tvar prefixLower = prefix.toLowerCase(Locale.ROOT);\n\t\tvar matchingUsers = users.stream()\n\t\t\t\t.filter(chatRoomUser -> !chatRoomUser.nickname().equals(nickname) && (isEmpty(prefix) || chatRoomUser.nickname().toLowerCase(Locale.ROOT).startsWith(prefixLower)))\n\t\t\t\t.toList();\n\n\t\tif (matchingUsers.isEmpty())\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn matchingUsers.get(index % matchingUsers.size()).nickname();\n\t}\n\n\tpublic void setNickname(String nickname)\n\t{\n\t\tthis.nickname = nickname;\n\t}\n\n\tpublic Node getChatView()\n\t{\n\t\treturn anchorPane;\n\t}\n\n\tprivate static void addToAnchorPane(Node chatView, AnchorPane anchorPane)\n\t{\n\t\t// We use an anchor to force the VirtualFlow to be bigger\n\t\t// than its default size of 100 x 100. It doesn't behave\n\t\t// well in a VBox only.\n\t\tanchorPane.getChildren().add(chatView);\n\t\tanchorPane.getStyleClass().add(\"chat-list-pane\");\n\t\tAnchorPane.setTopAnchor(chatView, 0.0);\n\t\tAnchorPane.setLeftAnchor(chatView, 0.0);\n\t\tAnchorPane.setRightAnchor(chatView, 0.0);\n\t\tAnchorPane.setBottomAnchor(chatView, 0.0);\n\t\tVBox.setVgrow(anchorPane, Priority.ALWAYS);\n\t}\n\n\tListView<ChatRoomUser> getUserListView()\n\t{\n\t\treturn userListView;\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tprivate void addMessageLine(ChatLine line)\n\t{\n\t\tmessages.add(line);\n\t\tjumpToBottom(false);\n\t\ttrimScrollBackIfNeeded();\n\t}\n\n\tprivate void addMessageLine(Instant when, ChatAction action, Image image)\n\t{\n\t\tvar chatLine = new ChatLine(when, action, List.of(new ContentImage(image, chatView)));\n\t\taddMessageLine(chatLine);\n\t}\n\n\tprivate void addMessageLine(ChatAction action)\n\t{\n\t\tvar chatLine = new ChatLine(Instant.now(), action, List.of());\n\t\taddMessageLine(chatLine);\n\t}\n\n\t/**\n\t * Jumps to the bottom of the chat listview.\n\t *\n\t * @param force always jumps, otherwise it will only jump if it was already at the bottom at the last layout\n\t */\n\tpublic void jumpToBottom(boolean force)\n\t{\n\t\tif (force || messages.size() - chatView.getContent().getLastVisibleIndex() <= 2)\n\t\t{\n\t\t\tchatView.getContent().showAsFirst(messages.size());\n\t\t}\n\t}\n\n\tprivate void trimScrollBackIfNeeded()\n\t{\n\t\tif (messages.size() >= SCROLL_BACK_MAX_LINES)\n\t\t{\n\t\t\tmessages.remove(0, SCROLL_BACK_CLEANUP_THRESHOLD);\n\t\t}\n\t}\n\n\tprivate void createUsersListViewContextMenu(Node view)\n\t{\n\t\tvar infoItem = new MenuItem(bundle.getString(\"chat.room.user-menu\"));\n\t\tinfoItem.setId(INFO_MENU_ID);\n\t\tinfoItem.setGraphic(new FontIcon(MaterialDesignA.ACCOUNT_BOX));\n\t\tinfoItem.setOnAction(event -> {\n\t\t\tvar user = (ChatRoomUser) event.getSource();\n\t\t\turiAction.openUri(new IdentityUri(user.nickname(), user.gxsId(), null));\n\t\t});\n\n\t\tvar chatItem = new MenuItem(bundle.getString(\"contact-view.action.chat\"));\n\t\tchatItem.setId(CHAT_MENU_ID);\n\t\tchatItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT));\n\t\tchatItem.setOnAction(event -> {\n\t\t\tvar user = (ChatRoomUser) event.getSource();\n\t\t\twindowManager.openMessaging(user.gxsId());\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<ChatRoomUser>(chatItem, infoItem);\n\t\txContextMenu.setOnShowing((cm, chatRoomUser) -> {\n\t\t\tif (chatRoomUser == null)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tcm.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> CHAT_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(chatRoomUser.identityId() == OWN_IDENTITY_ID));\n\n\t\t\treturn chatRoomUser.gxsId() != null;\n\t\t});\n\t\txContextMenu.addToNode(view);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatListViewContextMenu.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport javafx.event.ActionEvent;\nimport javafx.event.EventHandler;\nimport javafx.scene.Node;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\n\nimport java.util.Optional;\nimport java.util.ResourceBundle;\n\nclass ChatListViewContextMenu\n{\n\tprivate static final String CLEAR_HISTORY_MENU_ID = \"clearHistory\";\n\tprivate static final String COPY_SELECTION_MENU_ID = \"copySelection\";\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tprivate final ContextMenu contextMenu;\n\n\tpublic ChatListViewContextMenu()\n\t{\n\t\tcontextMenu = new ContextMenu();\n\t}\n\n\tpublic void show(Node anchor, double screenX, double screenY)\n\t{\n\t\tcontextMenu.show(anchor, screenX, screenY);\n\t}\n\n\tpublic void hide()\n\t{\n\t\tcontextMenu.hide();\n\t}\n\n\tpublic void installSelectionMenu(EventHandler<ActionEvent> eventHandler)\n\t{\n\t\tif (findMenuEntry(COPY_SELECTION_MENU_ID).isPresent())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar copySelectionItem = new MenuItem(bundle.getString(\"chat.room.copy-selection\"));\n\t\tcopySelectionItem.setId(COPY_SELECTION_MENU_ID);\n\t\tcopySelectionItem.setOnAction(eventHandler);\n\n\t\tcontextMenu.getItems().addFirst(copySelectionItem);\n\t}\n\n\tpublic void removeSelectionMenu()\n\t{\n\t\tremoveMenuEntry(COPY_SELECTION_MENU_ID);\n\t}\n\n\tpublic void installClearHistoryMenu(EventHandler<ActionEvent> eventHandler)\n\t{\n\t\tif (findMenuEntry(CLEAR_HISTORY_MENU_ID).isPresent())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar clearItem = new MenuItem(bundle.getString(\"chat.room.clear-chat-history\"));\n\t\tclearItem.setId(CLEAR_HISTORY_MENU_ID);\n\t\tclearItem.setOnAction(eventHandler);\n\n\t\tcontextMenu.getItems().addAll(clearItem);\n\t}\n\n\tpublic void removeClearHistoryMenu()\n\t{\n\t\tremoveMenuEntry(CLEAR_HISTORY_MENU_ID);\n\t}\n\n\tprivate void removeMenuEntry(String id)\n\t{\n\t\tfindMenuEntry(id).ifPresent(menuItem -> contextMenu.getItems().remove(menuItem));\n\t}\n\n\tprivate Optional<MenuItem> findMenuEntry(String id)\n\t{\n\t\treturn contextMenu.getItems().stream()\n\t\t\t\t.filter(menuItem -> menuItem.getId().equals(id))\n\t\t\t\t.findFirst();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomCell.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.message.chat.RoomType;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TreeCell;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\npublic class ChatRoomCell extends TreeCell<RoomHolder>\n{\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tpublic ChatRoomCell()\n\t{\n\t\tsuper();\n\t\tTooltipUtils.install(this,\n\t\t\t\t() -> {\n\t\t\t\t\tvar roomInfo = getItem().getRoomInfo();\n\t\t\t\t\tif (roomInfo.getId() == 0)\n\t\t\t\t\t{\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t\treturn MessageFormat.format(bundle.getString(\"chat.room.info\"),\n\t\t\t\t\t\t\t(StringUtils.isNotBlank(roomInfo.getTopic()) ? roomInfo.getTopic() : bundle.getString(\"chat.room.none\")),\n\t\t\t\t\t\t\troomInfo.getCount(),\n\t\t\t\t\t\t\tString.join(\", \", roomInfo.getRoomType() == RoomType.PRIVATE ? bundle.getString(\"chat.room.private\") : bundle.getString(\"chat.room.public\"), roomInfo.isSigned() ? bundle.getString(\"chat.room.signed-only\") : bundle.getString(\"chat.room.anonymous-allowed\")),\n\t\t\t\t\t\t\tId.toString(getItem().getRoomInfo().getId()));\n\t\t\t\t}\n\t\t\t\t, null);\n\t}\n\n\t@Override\n\tprotected void updateItem(RoomHolder item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tsetText(null);\n\t\t\tsetStyle(\"\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetText(item.getRoomInfo().getName());\n\t\t\tif (item.getRoomInfo().hasNewMessages())\n\t\t\t{\n\t\t\t\tif (item.getRoomInfo().getRoomType() == RoomType.PRIVATE)\n\t\t\t\t{\n\t\t\t\t\tsetStyle(\"-fx-text-fill: teal; -fx-font-weight: bold;\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tsetStyle(\"-fx-font-weight: bold;\");\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (item.getRoomInfo().getRoomType() == RoomType.PRIVATE)\n\t\t\t\t{\n\t\t\t\t\tsetStyle(\"-fx-text-fill: teal;\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tsetStyle(\"\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomCreationWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.rest.chat.ChatRoomVisibility;\nimport io.xeres.ui.client.ChatClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.collections.FXCollections;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.CheckBox;\nimport javafx.scene.control.ChoiceBox;\nimport javafx.scene.control.TextField;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/chat/chatroom_create.fxml\")\npublic class ChatRoomCreationWindowController implements WindowController\n{\n\t@FXML\n\tprivate Button createButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\t@FXML\n\tprivate TextField roomName;\n\n\t@FXML\n\tprivate TextField topic;\n\n\t@FXML\n\tprivate ChoiceBox<String> visibility;\n\n\t@FXML\n\tprivate CheckBox security;\n\n\tprivate final ChatClient chatClient;\n\tprivate final ResourceBundle bundle;\n\n\tpublic ChatRoomCreationWindowController(ChatClient chatClient, ResourceBundle bundle)\n\t{\n\t\tthis.chatClient = chatClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\troomName.textProperty().addListener(_ -> checkCreatable());\n\t\ttopic.textProperty().addListener(_ -> checkCreatable());\n\n\t\tvisibility.setItems(FXCollections.observableArrayList(bundle.getString(\"enum.room-type.public\"), bundle.getString(\"enum.room-type.private\")));\n\t\tvisibility.getSelectionModel().select(0);\n\n\t\tcreateButton.setOnAction(_ -> chatClient.createChatRoom(roomName.getText(),\n\t\t\t\t\t\ttopic.getText(),\n\t\t\t\t\t\tChatRoomVisibility.fromSelection(visibility.getSelectionModel().getSelectedIndex()),\n\t\t\t\t\t\tsecurity.isSelected())\n\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(roomName)))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.subscribe());\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\t}\n\n\tprivate void checkCreatable()\n\t{\n\t\tcreateButton.setDisable(roomName.getText().isBlank() || topic.getText().isBlank());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomInfoController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.message.chat.ChatRoomInfo;\nimport io.xeres.common.message.chat.RoomType;\nimport io.xeres.ui.controller.Controller;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Label;\nimport javafx.scene.layout.GridPane;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.ResourceBundle;\n\npublic class ChatRoomInfoController implements Controller\n{\n\t@FXML\n\tprivate GridPane roomGroup;\n\n\t@FXML\n\tprivate Label roomName;\n\n\t@FXML\n\tprivate Label roomId;\n\n\t@FXML\n\tprivate Label roomTopic;\n\n\t@FXML\n\tprivate Label roomSecurity;\n\n\t@FXML\n\tprivate Label roomCount;\n\n\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\t// Clear the display first\n\t\tsetRoomInfo(null);\n\t}\n\n\tpublic void setRoomInfo(ChatRoomInfo chatRoomInfo)\n\t{\n\t\tif (chatRoomInfo != null && chatRoomInfo.isReal())\n\t\t{\n\t\t\troomGroup.setVisible(true);\n\t\t\troomName.setText(chatRoomInfo.getName());\n\t\t\troomId.setText(Id.toString(chatRoomInfo.getId()));\n\t\t\troomTopic.setText(StringUtils.isNotBlank(chatRoomInfo.getTopic()) ? chatRoomInfo.getTopic() : bundle.getString(\"chat.room.none\"));\n\t\t\troomSecurity.setText(String.join(\", \", chatRoomInfo.getRoomType() == RoomType.PRIVATE ? bundle.getString(\"chat.room.private\") : bundle.getString(\"chat.room.public\"), chatRoomInfo.isSigned() ? bundle.getString(\"chat.room.signed-only\") : bundle.getString(\"chat.room.anonymous-allowed\")));\n\t\t\troomCount.setText(String.valueOf(chatRoomInfo.getCount()));\n\t\t}\n\t\telse\n\t\t{\n\t\t\troomGroup.setVisible(false);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomInvitationWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.ui.client.ChatClient;\nimport io.xeres.ui.client.ConnectionClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.event.ActionEvent;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.CheckBoxTreeItem;\nimport javafx.scene.control.SelectionMode;\nimport javafx.scene.control.TreeView;\nimport javafx.scene.control.cell.CheckBoxTreeCell;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.HashSet;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\n@Component\n@FxmlView(value = \"/view/chat/chatroom_invite.fxml\")\npublic class ChatRoomInvitationWindowController implements WindowController\n{\n\t@FXML\n\tprivate TreeView<PeerHolder> peersTree;\n\n\t@FXML\n\tprivate Button inviteButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\tprivate final ConnectionClient connectionClient;\n\tprivate final ChatClient chatClient;\n\n\tprivate final Set<CheckBoxTreeItem<PeerHolder>> invitedItems = new HashSet<>();\n\tprivate long chatRoomId;\n\n\tpublic ChatRoomInvitationWindowController(ConnectionClient connectionClient, ChatClient chatClient)\n\t{\n\t\tthis.connectionClient = connectionClient;\n\t\tthis.chatClient = chatClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tvar root = new CheckBoxTreeItem<>(new PeerHolder());\n\t\troot.setExpanded(true);\n\t\troot.addEventHandler(\n\t\t\t\tCheckBoxTreeItem.checkBoxSelectionChangedEvent(),\n\t\t\t\t(CheckBoxTreeItem.TreeModificationEvent<PeerHolder> e) -> {\n\t\t\t\t\tvar item = e.getTreeItem();\n\t\t\t\t\tif (item.isLeaf())\n\t\t\t\t\t{\n\t\t\t\t\t\tcheckInvite(item);\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\tpeersTree.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); // XXX: needed?\n\t\tpeersTree.setRoot(root);\n\t\tpeersTree.setShowRoot(false);\n\t\tpeersTree.setCellFactory(CheckBoxTreeCell.forTreeView());\n\n\t\tconnectionClient.getConnectedProfiles().collectList()\n\t\t\t\t.doOnSuccess(profiles -> Platform.runLater(() -> {\n\t\t\t\t\tassert profiles != null;\n\t\t\t\t\tprofiles.forEach(profile -> {\n\t\t\t\t\t\tif (profile.getLocations().size() == 1)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\troot.getChildren().add(new CheckBoxTreeItem<>(new PeerHolder(profile, profile.getLocations().getFirst())));\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar parent = new CheckBoxTreeItem<>(new PeerHolder(profile));\n\t\t\t\t\t\t\tparent.setExpanded(true);\n\t\t\t\t\t\t\troot.getChildren().add(parent);\n\t\t\t\t\t\t\tprofile.getLocations().stream()\n\t\t\t\t\t\t\t\t\t.filter(Location::isConnected)\n\t\t\t\t\t\t\t\t\t.forEach(location -> parent.getChildren().add(new CheckBoxTreeItem<>(new PeerHolder(profile, location))));\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}))\n\t\t\t\t.doAfterTerminate(() -> root.setSelected(false))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.subscribe();\n\n\t\tinviteButton.setOnAction(this::invitePeers);\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\n\t\tPlatform.runLater(this::handleArgument);\n\t}\n\n\tprivate void handleArgument()\n\t{\n\t\tvar userData = UiUtils.getUserData(inviteButton);\n\t\tif (userData != null)\n\t\t{\n\t\t\tchatRoomId = (long) userData;\n\t\t}\n\t}\n\n\tprivate void checkInvite(CheckBoxTreeItem<PeerHolder> item)\n\t{\n\t\tif (item.isSelected())\n\t\t{\n\t\t\tinvitedItems.add(item);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tinvitedItems.remove(item);\n\t\t}\n\t\tinviteButton.setDisable(invitedItems.isEmpty());\n\t}\n\n\tprivate void invitePeers(ActionEvent event)\n\t{\n\t\tvar selectedLocations = invitedItems.stream()\n\t\t\t\t.map(peerHolderTreeItem -> peerHolderTreeItem.getValue().getLocation())\n\t\t\t\t.collect(Collectors.toSet());\n\n\t\tinvitedItems.clear();\n\t\tpeersTree.setRoot(null);\n\n\t\tchatClient.inviteLocationsToChatRoom(chatRoomId, selectedLocations)\n\t\t\t\t.subscribe();\n\n\t\tUiUtils.closeWindow(event);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatRoomUser.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.id.GxsId;\n\nrecord ChatRoomUser(GxsId gxsId, String nickname, long identityId)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatUserCell.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.ListCell;\nimport javafx.scene.image.ImageView;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\n\nclass ChatUserCell extends ListCell<ChatRoomUser>\n{\n\tprivate static final int AVATAR_WIDTH = 32;\n\tprivate static final int AVATAR_HEIGHT = 32;\n\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tpublic ChatUserCell(GeneralClient generalClient, ImageCache imageCache)\n\t{\n\t\tsuper();\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tTooltipUtils.install(this,\n\t\t\t\t() -> MessageFormat.format(bundle.getString(\"chat.room.user-info\"), super.getItem().nickname(), super.getItem().gxsId()),\n\t\t\t\t() -> new ImageView(((ImageView) super.getGraphic()).getImage()));\n\t}\n\n\t@Override\n\tprotected void updateItem(ChatRoomUser item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.nickname());\n\t\tsetGraphic(empty ? null : updateAvatar((AsyncImageView) getGraphic(), item));\n\t}\n\n\tprivate AsyncImageView updateAvatar(AsyncImageView asyncImageView, ChatRoomUser item)\n\t{\n\t\tif (asyncImageView == null)\n\t\t{\n\t\t\tasyncImageView = new AsyncImageView(\n\t\t\t\t\turl -> generalClient.getImage(url).block(),\n\t\t\t\t\timageCache);\n\t\t\tasyncImageView.setFitWidth(AVATAR_WIDTH);\n\t\t\tasyncImageView.setFitHeight(AVATAR_HEIGHT);\n\t\t}\n\n\t\tasyncImageView.setUrl(getImageUrl(item));\n\n\t\treturn asyncImageView;\n\t}\n\n\tprivate String getImageUrl(ChatRoomUser item)\n\t{\n\t\tif (item.identityId() != 0L)\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/\" + item.identityId() + \"/image\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/image?gxsId=\" + item.gxsId();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.message.chat.*;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.rest.notification.contact.AddOrUpdateContacts;\nimport io.xeres.common.rest.notification.contact.RemoveContacts;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.common.util.image.ImageUtils;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.chat.ChatListView.AddUserOrigin;\nimport io.xeres.ui.custom.InputAreaGroup;\nimport io.xeres.ui.custom.TypingNotificationView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.custom.event.FileSelectedEvent;\nimport io.xeres.ui.custom.event.ImageSelectedEvent;\nimport io.xeres.ui.custom.event.StickerSelectedEvent;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.event.UnreadEvent;\nimport io.xeres.ui.support.chat.ChatCommand;\nimport io.xeres.ui.support.chat.NicknameCompleter;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.sound.SoundPlayerService;\nimport io.xeres.ui.support.sound.SoundPlayerService.SoundType;\nimport io.xeres.ui.support.tray.TrayService;\nimport io.xeres.ui.support.unread.UnreadService;\nimport io.xeres.ui.support.uri.ChatRoomUri;\nimport io.xeres.ui.support.uri.FileUriFactory;\nimport io.xeres.ui.support.uri.UriService;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.TextInputControlUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.animation.KeyFrame;\nimport javafx.animation.Timeline;\nimport javafx.application.Platform;\nimport javafx.collections.ObservableList;\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Node;\nimport javafx.scene.control.*;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyCodeCombination;\nimport javafx.scene.input.KeyCombination;\nimport javafx.scene.input.KeyEvent;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.layout.Priority;\nimport javafx.scene.layout.VBox;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignL;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport javax.imageio.ImageIO;\nimport java.awt.image.BufferedImage;\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.text.MessageFormat;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.ResourceBundle;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Consumer;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nimport static io.xeres.common.message.chat.ChatConstants.TYPING_NOTIFICATION_DELAY;\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\nimport static io.xeres.ui.support.preference.PreferenceUtils.CHAT_ROOMS;\nimport static javafx.scene.control.Alert.AlertType.WARNING;\nimport static org.apache.commons.lang3.ObjectUtils.isEmpty;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Component\n@FxmlView(value = \"/view/chat/chat_view.fxml\")\npublic class ChatViewController implements Controller\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ChatViewController.class);\n\n\tprivate static final int PREVIEW_IMAGE_WIDTH_MAX = 320;\n\tprivate static final int PREVIEW_IMAGE_HEIGHT_MAX = 240;\n\n\tprivate static final int STICKER_WIDTH_MAX = 192;\n\tprivate static final int STICKER_HEIGHT_MAX = 192;\n\n\tprivate static final int MESSAGE_MAXIMUM_SIZE = 31000; // XXX: put that on chat service too as we shouldn't forward them. also this is only for chat rooms, not private chats\n\tprivate static final KeyCodeCombination TAB_KEY = new KeyCodeCombination(KeyCode.TAB);\n\tprivate static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination CTRL_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN);\n\tprivate static final KeyCodeCombination SHIFT_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN);\n\tprivate static final KeyCodeCombination ENTER_KEY = new KeyCodeCombination(KeyCode.ENTER);\n\tprivate static final KeyCodeCombination BACKSPACE_KEY = new KeyCodeCombination(KeyCode.BACK_SPACE);\n\tprivate static final String SUBSCRIBED_MENU_ID = \"subscribed\";\n\tprivate static final String UNSUBSCRIBED_MENU_ID = \"unsubscribed\";\n\tprivate static final String COPY_LINK_MENU_ID = \"copyLink\";\n\n\tprivate static final String OPEN_SUBSCRIBED = \"OpenSubscribed\";\n\tprivate static final String OPEN_PRIVATE = \"OpenPrivate\";\n\tprivate static final String OPEN_PUBLIC = \"OpenPublic\";\n\n\t@FXML\n\tprivate TreeView<RoomHolder> roomTree;\n\n\t@FXML\n\tprivate SplitPane splitPane;\n\n\t@FXML\n\tprivate VBox content;\n\n\t@FXML\n\tprivate InputAreaGroup send;\n\n\t@FXML\n\tprivate VBox sendGroup;\n\n\t@FXML\n\tprivate TypingNotificationView typingNotification;\n\n\t@FXML\n\tprivate HBox previewGroup;\n\n\t@FXML\n\tprivate ImageView imagePreview;\n\n\t@FXML\n\tprivate Button previewSend;\n\n\t@FXML\n\tprivate Button previewCancel;\n\n\t@FXML\n\tprivate VBox userListContent;\n\n\t@FXML\n\tprivate Button invite;\n\n\t@FXML\n\tprivate HBox status;\n\n\t@FXML\n\tprivate Label roomName;\n\n\t@FXML\n\tprivate Label roomTopic;\n\n\t@FXML\n\tpublic Button createChatRoom;\n\n\tprivate final MessageClient messageClient;\n\tprivate final ChatClient chatClient;\n\tprivate final ProfileClient profileClient;\n\tprivate final LocationClient locationClient;\n\tprivate final WindowManager windowManager;\n\tprivate final TrayService trayService;\n\tprivate final ResourceBundle bundle;\n\tprivate final MarkdownService markdownService;\n\tprivate final UriService uriService;\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\tprivate final SoundPlayerService soundPlayerService;\n\tprivate final ShareClient shareClient;\n\tprivate final UnreadService unreadService;\n\tprivate final NotificationClient notificationClient;\n\n\tprivate final TreeItem<RoomHolder> subscribedRooms;\n\tprivate final TreeItem<RoomHolder> privateRooms;\n\tprivate final TreeItem<RoomHolder> publicRooms;\n\n\tprivate String nickname;\n\n\tprivate final NicknameCompleter nicknameCompleter = new NicknameCompleter();\n\tprivate ChatRoomInfo selectedRoom;\n\tprivate ChatListView selectedChatListView;\n\tprivate Node roomInfoView;\n\tprivate ChatRoomInfoController chatRoomInfoController;\n\n\tprivate Instant lastTypingNotification = Instant.EPOCH;\n\n\tprivate double[] dividerPositions;\n\n\tprivate Timeline lastTypingTimeline;\n\n\tprivate Disposable contactNotificationDisposable;\n\n\tpublic ChatViewController(MessageClient messageClient, ChatClient chatClient, ProfileClient profileClient, LocationClient locationClient, WindowManager windowManager, TrayService trayService, ResourceBundle bundle, MarkdownService markdownService, UriService uriService, GeneralClient generalClient, ImageCache imageCache, SoundPlayerService soundPlayerService, ShareClient shareClient, UnreadService unreadService, NotificationClient notificationClient)\n\t{\n\t\tthis.messageClient = messageClient;\n\t\tthis.chatClient = chatClient;\n\t\tthis.profileClient = profileClient;\n\t\tthis.locationClient = locationClient;\n\t\tthis.windowManager = windowManager;\n\t\tthis.trayService = trayService;\n\t\tthis.bundle = bundle;\n\t\tthis.markdownService = markdownService;\n\t\tthis.uriService = uriService;\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tthis.soundPlayerService = soundPlayerService;\n\t\tthis.shareClient = shareClient;\n\t\tthis.unreadService = unreadService;\n\t\tthis.notificationClient = notificationClient;\n\n\t\tsubscribedRooms = new TreeItem<>(new RoomHolder(bundle.getString(\"subscribed\")));\n\t\tprivateRooms = new TreeItem<>(new RoomHolder(bundle.getString(\"enum.room-type.private\")));\n\t\tpublicRooms = new TreeItem<>(new RoomHolder(bundle.getString(\"enum.room-type.public\")));\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tprofileClient.getOwn().doOnSuccess(profile -> Platform.runLater(() -> {\n\t\t\t\t\tassert profile != null;\n\t\t\t\t\tinitializeReally(profile.getName());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tsetupIdentityNotifications();\n\t}\n\n\tprivate void initializeReally(String nickname)\n\t{\n\t\tthis.nickname = nickname;\n\n\t\tvar root = new TreeItem<>(new RoomHolder());\n\t\t//noinspection unchecked\n\t\troot.getChildren().addAll(subscribedRooms, privateRooms, publicRooms);\n\t\troot.setExpanded(true);\n\t\troomTree.setRoot(root);\n\t\troomTree.setShowRoot(false);\n\t\troomTree.setCellFactory(_ -> new ChatRoomCell());\n\t\tcreateRoomTreeContextMenu();\n\n\t\t// We need Platform.runLater() because when an entry is moved, the selection can change\n\t\troomTree.getSelectionModel().selectedItemProperty()\n\t\t\t\t.addListener((_, _, newValue) -> Platform.runLater(() -> changeSelectedRoom(newValue)));\n\n\t\tUiUtils.setOnPrimaryMouseDoubleClicked(roomTree, _ -> {\n\t\t\tif (isRoomSelected())\n\t\t\t{\n\t\t\t\tjoinChatRoom(selectedRoom);\n\t\t\t}\n\t\t});\n\n\t\tvar loader = new FXMLLoader(ChatViewController.class.getResource(\"/view/chat/chat_roominfo.fxml\"), bundle);\n\t\ttry\n\t\t{\n\t\t\troomInfoView = loader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t\tchatRoomInfoController = loader.getController();\n\n\t\tlastTypingTimeline = new Timeline(new KeyFrame(javafx.util.Duration.seconds(TYPING_NOTIFICATION_DELAY.getSeconds())));\n\t\tlastTypingTimeline.setOnFinished(_ -> typingNotification.setText(\"\"));\n\n\t\tVBox.setVgrow(roomInfoView, Priority.ALWAYS);\n\t\tswitchChatContent(roomInfoView, null);\n\t\tsendGroup.setVisible(false);\n\t\tsetPreviewGroupVisibility(false);\n\n\t\tpreviewSend.setOnAction(_ -> sendImage());\n\t\tpreviewCancel.setOnAction(_ -> cancelImage());\n\n\t\t// Handle the events even if the InputArea widget isn't selected\n\t\tcontent.addEventHandler(KeyEvent.KEY_PRESSED, this::handleInputKeys);\n\n\t\tsend.addKeyFilter(this::handleInputKeys);\n\t\tsend.addEnhancedContextMenu(this::handlePaste, locationClient);\n\n\t\tsend.addEventHandler(StickerSelectedEvent.STICKER_SELECTED, event -> CompletableFuture.runAsync(() -> {\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar bufferedImage = ImageIO.read(event.getPath().toFile());\n\t\t\t\tPlatform.runLater(() -> sendStickerToMessage(bufferedImage));\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tlog.error(\"Couldn't send the sticker: {}\", e.getMessage());\n\t\t\t}\n\t\t}));\n\n\t\tsend.addEventHandler(ImageSelectedEvent.IMAGE_SELECTED, event -> {\n\t\t\tif (event.getFile().canRead())\n\t\t\t{\n\t\t\t\tCompletableFuture.runAsync(() -> {\n\t\t\t\t\ttry (var inputStream = new FileInputStream(event.getFile()))\n\t\t\t\t\t{\n\t\t\t\t\t\tvar image = new Image(inputStream);\n\t\t\t\t\t\tPlatform.runLater(() -> setPreviewImage(image));\n\t\t\t\t\t}\n\t\t\t\t\tcatch (IOException e)\n\t\t\t\t\t{\n\t\t\t\t\t\tUiUtils.showAlert(Alert.AlertType.ERROR, MessageFormat.format(bundle.getString(\"file-requester.error\"), event.getFile(), e.getMessage()));\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\n\t\tsend.addEventHandler(FileSelectedEvent.FILE_SELECTED, event -> {\n\t\t\tif (event.getFile().canRead())\n\t\t\t{\n\t\t\t\tsendFile(event.getFile());\n\t\t\t}\n\t\t});\n\n\t\tinvite.setOnAction(_ -> windowManager.openInvite(selectedRoom.getId()));\n\n\t\tgetChatRoomContext();\n\n\t\tcreateChatRoom.setOnAction(_ -> windowManager.openChatRoomCreation());\n\n\t\tsetupTrees();\n\t}\n\n\tprivate void setupIdentityNotifications()\n\t{\n\t\tcontactNotificationDisposable = notificationClient.getContactNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tList<Contact> contacts = switch (sse.data())\n\t\t\t\t\t{\n\t\t\t\t\t\tcase AddOrUpdateContacts action -> action.contacts();\n\t\t\t\t\t\tcase RemoveContacts action -> action.contacts();\n\t\t\t\t\t\tcase null -> throw new IllegalArgumentException(\"sse data is null for contacts\");\n\t\t\t\t\t};\n\t\t\t\t\trefreshUsers(contacts.stream()\n\t\t\t\t\t\t\t.map(Contact::identityId)\n\t\t\t\t\t\t\t.collect(Collectors.toSet()));\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void refreshUsers(Set<Long> identityIds)\n\t{\n\t\tsubscribedRooms.getChildren().forEach(room -> {\n\t\t\tvar chatListView = room.getValue().getChatListView();\n\t\t\tchatListView.getUserListView().getItems().forEach(chatRoomUser -> {\n\t\t\t\tvar found = false;\n\t\t\t\tif (identityIds.contains(chatRoomUser.identityId()))\n\t\t\t\t{\n\t\t\t\t\timageCache.evictImage(RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/\" + chatRoomUser.identityId() + \"/image\");\n\t\t\t\t\tfound = true;\n\t\t\t\t}\n\t\t\t\tif (found)\n\t\t\t\t{\n\t\t\t\t\tchatListView.getUserListView().refresh();\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate void sendFile(File file)\n\t{\n\t\tshareClient.createTemporaryShare(file.getAbsolutePath())\n\t\t\t\t.doOnSuccess(result -> {\n\t\t\t\t\tassert result != null;\n\t\t\t\t\tsendChatMessage(FileUriFactory.generate(file.getName(), getFileSize(file.toPath()), Sha1Sum.fromString(result.hash())));\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\t// XXX: duplicate..\n\tprivate static long getFileSize(Path path)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Files.size(path);\n\t\t}\n\t\tcatch (IOException _)\n\t\t{\n\t\t\tlog.error(\"Failed to get the file size of {}\", path);\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\tprivate void setupTrees()\n\t{\n\t\tvar node = PreferenceUtils.getPreferences().node(CHAT_ROOMS);\n\t\tsubscribedRooms.setExpanded(node.getBoolean(OPEN_SUBSCRIBED, false));\n\t\tprivateRooms.setExpanded(node.getBoolean(OPEN_PRIVATE, false));\n\t\tpublicRooms.setExpanded(node.getBoolean(OPEN_PUBLIC, false));\n\n\t\tsubscribedRooms.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_SUBSCRIBED, newValue));\n\t\tprivateRooms.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_PRIVATE, newValue));\n\t\tpublicRooms.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_PUBLIC, newValue));\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvents(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof ChatRoomUri chatRoomUri)\n\t\t{\n\t\t\tvar chatRoomId = chatRoomUri.id();\n\n\t\t\tgetAllTreeItem(chatRoomId).ifPresentOrElse(treeItem -> Platform.runLater(() -> roomTree.getSelectionModel().select(treeItem)),\n\t\t\t\t\t() -> UiUtils.showAlert(WARNING, bundle.getString(\"chat.room.not-found\")));\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (contactNotificationDisposable != null && !contactNotificationDisposable.isDisposed())\n\t\t{\n\t\t\tcontactNotificationDisposable.dispose();\n\t\t}\n\t}\n\n\tprivate void createRoomTreeContextMenu()\n\t{\n\t\tvar subscribeItem = new MenuItem(bundle.getString(\"chat.room.join\"));\n\t\tsubscribeItem.setId(SUBSCRIBED_MENU_ID);\n\t\tsubscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_ENTER));\n\t\tsubscribeItem.setOnAction(event -> joinChatRoom(((RoomHolder) event.getSource()).getRoomInfo()));\n\n\t\tvar unsubscribeItem = new MenuItem(bundle.getString(\"chat.room.leave\"));\n\t\tunsubscribeItem.setId(UNSUBSCRIBED_MENU_ID);\n\t\tunsubscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_EXIT));\n\t\tunsubscribeItem.setOnAction(event -> leaveChatRoom(((RoomHolder) event.getSource()).getRoomInfo()));\n\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy-link\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tcopyLinkItem.setOnAction(event -> {\n\t\t\tvar chatRoomInfo = ((RoomHolder) event.getSource()).getRoomInfo();\n\t\t\tClipboardUtils.copyTextToClipboard(new ChatRoomUri(chatRoomInfo.getName(), chatRoomInfo.getId()).toUriString());\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<RoomHolder>(subscribeItem, unsubscribeItem, new SeparatorMenuItem(), copyLinkItem);\n\t\txContextMenu.addToNode(roomTree);\n\t\txContextMenu.setOnShowing((contextMenu, roomHolder) -> {\n\t\t\tvar chatRoomInfo = roomHolder.getRoomInfo();\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> SUBSCRIBED_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(isAlreadyJoined(chatRoomInfo)));\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> UNSUBSCRIBED_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(!isAlreadyJoined(chatRoomInfo)));\n\n\t\t\treturn chatRoomInfo.isReal();\n\t\t});\n\t}\n\n\tprivate boolean isAlreadyJoined(ChatRoomInfo chatRoomInfo)\n\t{\n\t\treturn subscribedRooms.getChildren().stream()\n\t\t\t\t.anyMatch(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().equals(chatRoomInfo));\n\t}\n\n\tprivate void joinChatRoom(ChatRoomInfo chatRoomInfo)\n\t{\n\t\tif (!isAlreadyJoined(chatRoomInfo))\n\t\t{\n\t\t\tchatClient.joinChatRoom(chatRoomInfo.getId())\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tprivate void leaveChatRoom(ChatRoomInfo chatRoomInfo)\n\t{\n\t\tsubscribedRooms.getChildren().stream()\n\t\t\t\t.filter(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().equals(chatRoomInfo))\n\t\t\t\t.findAny()\n\t\t\t\t.ifPresent(_ -> chatClient.leaveChatRoom(chatRoomInfo.getId())\n\t\t\t\t\t\t.subscribe());\n\t}\n\n\tprivate void getChatRoomContext()\n\t{\n\t\tchatClient.getChatRoomContext()\n\t\t\t\t.doOnSuccess(context -> {\n\t\t\t\t\tassert context != null;\n\t\t\t\t\taddRooms(context.chatRoomLists());\n\t\t\t\t\tcontext.chatRoomLists().getSubscribedRooms().forEach(chatRoomInfo -> userJoined(chatRoomInfo.getId(), new ChatRoomUserEvent(context.ownUser().gxsId(), context.ownUser().nickname(), context.ownUser().identityId())));\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tpublic void addRooms(ChatRoomLists chatRoomLists)\n\t{\n\t\tvar subscribedTree = subscribedRooms.getChildren();\n\t\tvar publicTree = publicRooms.getChildren();\n\t\tvar privateTree = privateRooms.getChildren();\n\n\t\tchatRoomLists.getSubscribedRooms()\n\t\t\t\t.forEach(roomInfo -> addOrUpdate(subscribedTree, roomInfo));\n\n\t\t// Make sure we don't add rooms that we're already subscribed to\n\t\tvar unsubscribedRooms = chatRoomLists.getAvailableRooms().stream()\n\t\t\t\t.filter(roomInfo -> !isInside(subscribedTree, roomInfo))\n\t\t\t\t.toList();\n\n\t\tsyncTreeWithChatRoomList(publicTree, unsubscribedRooms.stream()\n\t\t\t\t.filter(roomInfo -> roomInfo.getRoomType() == RoomType.PUBLIC)\n\t\t\t\t.toList());\n\n\t\tsyncTreeWithChatRoomList(privateTree, unsubscribedRooms.stream()\n\t\t\t\t.filter(roomInfo -> roomInfo.getRoomType() == RoomType.PRIVATE)\n\t\t\t\t.toList());\n\t}\n\n\tprivate void syncTreeWithChatRoomList(ObservableList<TreeItem<RoomHolder>> tree, List<ChatRoomInfo> list)\n\t{\n\t\tlist.forEach(chatRoomInfo -> addOrUpdate(tree, chatRoomInfo));\n\n\t\tvar chatRoomIds = list.stream()\n\t\t\t\t.map(ChatRoomInfo::getId)\n\t\t\t\t.collect(Collectors.toSet());\n\t\ttree.removeIf(roomHolderTreeItem -> !chatRoomIds.contains(roomHolderTreeItem.getValue().getRoomInfo().getId()));\n\t}\n\n\tpublic void roomJoined(long roomId)\n\t{\n\t\t// Must be idempotent\n\t\tmoveRoom(roomId, publicRooms, subscribedRooms);\n\t\tmoveRoom(roomId, privateRooms, subscribedRooms);\n\t}\n\n\tpublic void roomLeft(long roomId)\n\t{\n\t\t// Must be idempotent\n\t\tsubscribedRooms.getChildren().stream()\n\t\t\t\t.filter(roomInfoTreeItem -> roomInfoTreeItem.getValue().getRoomInfo().getId() == roomId)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(roomHolderTreeItem -> {\n\t\t\t\t\tsubscribedRooms.getChildren().remove(roomHolderTreeItem);\n\t\t\t\t\tif (roomHolderTreeItem.getValue().getRoomInfo().getRoomType() == RoomType.PRIVATE)\n\t\t\t\t\t{\n\t\t\t\t\t\tprivateRooms.getChildren().add(roomHolderTreeItem);\n\t\t\t\t\t\tsortByName(privateRooms.getChildren());\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tpublicRooms.getChildren().add(roomHolderTreeItem);\n\t\t\t\t\t\tsortByName(publicRooms.getChildren());\n\t\t\t\t\t}\n\t\t\t\t\troomHolderTreeItem.getValue().clearChatListView();\n\t\t\t\t});\n\t}\n\n\tprivate static void moveRoom(long roomId, TreeItem<RoomHolder> from, TreeItem<RoomHolder> to)\n\t{\n\t\tfrom.getChildren().stream()\n\t\t\t\t.filter(roomInfoTreeItem -> roomInfoTreeItem.getValue().getRoomInfo().getId() == roomId)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(roomHolderTreeItem -> {\n\t\t\t\t\tfrom.getChildren().remove(roomHolderTreeItem);\n\t\t\t\t\tto.getChildren().add(roomHolderTreeItem);\n\t\t\t\t\tsortByName(to.getChildren());\n\t\t\t\t});\n\t}\n\n\tprivate static void sortByName(ObservableList<TreeItem<RoomHolder>> children)\n\t{\n\t\tchildren.sort((o1, o2) -> o1.getValue().getRoomInfo().getName().compareToIgnoreCase(o2.getValue().getRoomInfo().getName()));\n\t}\n\n\tpublic void userJoined(long roomId, ChatRoomUserEvent event)\n\t{\n\t\tperformOnChatListView(roomId, chatListView -> chatListView.addUser(event, AddUserOrigin.JOIN));\n\t}\n\n\tpublic void userLeft(long roomId, ChatRoomUserEvent event)\n\t{\n\t\tperformOnChatListView(roomId, chatListView -> chatListView.removeUser(event));\n\t}\n\n\tpublic void userKeepAlive(long roomId, ChatRoomUserEvent event)\n\t{\n\t\tperformOnChatListView(roomId, chatListView -> chatListView.addUser(event, AddUserOrigin.KEEP_ALIVE));\n\t}\n\n\tpublic void userTimeout(long roomId, ChatRoomTimeoutEvent event)\n\t{\n\t\tperformOnChatListView(roomId, chatListView -> chatListView.timeoutUser(event));\n\t}\n\n\tpublic void jumpToBottom()\n\t{\n\t\tif (selectedChatListView != null)\n\t\t{\n\t\t\tselectedChatListView.jumpToBottom(true);\n\t\t}\n\t}\n\n\tprivate void switchChatContent(Node contentNode, Node userListNode)\n\t{\n\t\tif (content.getChildren().size() > 1)\n\t\t{\n\t\t\tcontent.getChildren().removeFirst();\n\t\t}\n\t\tcontent.getChildren().addFirst(contentNode);\n\n\t\tif (userListNode == null)\n\t\t{\n\t\t\tif (!isEmpty(userListContent.getChildren()))\n\t\t\t{\n\t\t\t\tuserListContent.getChildren().removeFirst();\n\t\t\t}\n\t\t\tif (splitPane.getItems().contains(userListContent))\n\t\t\t{\n\t\t\t\tdividerPositions = splitPane.getDividerPositions();\n\t\t\t\tsplitPane.getItems().remove(userListContent);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (!isEmpty(userListContent.getChildren()))\n\t\t\t{\n\t\t\t\tuserListContent.getChildren().removeFirst();\n\t\t\t}\n\t\t\tuserListContent.getChildren().addFirst(userListNode);\n\n\t\t\tif (!splitPane.getItems().contains(userListContent))\n\t\t\t{\n\t\t\t\tsplitPane.getItems().add(userListContent);\n\t\t\t\tsplitPane.setDividerPositions(dividerPositions);\n\t\t\t}\n\t\t}\n\t\tlastTypingTimeline.jumpTo(javafx.util.Duration.INDEFINITE);\n\t}\n\n\t// right now I use a simple implementation. It also has a drawback that it doesn't update the counter\n\tprivate static void addOrUpdate(ObservableList<TreeItem<RoomHolder>> tree, ChatRoomInfo chatRoomInfo)\n\t{\n\t\tif (tree.stream()\n\t\t\t\t.map(TreeItem::getValue)\n\t\t\t\t.noneMatch(existingRoom -> existingRoom.getRoomInfo().equals(chatRoomInfo)))\n\t\t{\n\t\t\ttree.add(new TreeItem<>(new RoomHolder(chatRoomInfo)));\n\t\t\tsortByName(tree);\n\t\t}\n\t}\n\n\tprivate static boolean isInside(ObservableList<TreeItem<RoomHolder>> tree, ChatRoomInfo chatRoomInfo)\n\t{\n\t\treturn tree.stream()\n\t\t\t\t.map(TreeItem::getValue)\n\t\t\t\t.anyMatch(roomHolder -> roomHolder.getRoomInfo().equals(chatRoomInfo));\n\t}\n\n\tprivate void changeSelectedRoom(TreeItem<RoomHolder> treeItem)\n\t{\n\t\tvar chatRoomInfo = treeItem != null ? treeItem.getValue().getRoomInfo() : null;\n\t\tselectedRoom = chatRoomInfo;\n\n\t\tgetSubscribedTreeItem(chatRoomInfo != null ? chatRoomInfo.getId() : 0L).ifPresentOrElse(roomInfoTreeItem -> {\n\t\t\tassert chatRoomInfo != null;\n\t\t\tvar chatListView = getChatListViewOrCreate(roomInfoTreeItem);\n\t\t\tselectedChatListView = chatListView;\n\t\t\tswitchChatContent(chatListView.getChatView(), chatListView.getUserListView());\n\t\t\troomName.setText(chatRoomInfo.getName());\n\t\t\troomTopic.setText(chatRoomInfo.getTopic());\n\t\t\tstatus.setVisible(true);\n\t\t\tsendGroup.setVisible(true);\n\t\t\tsend.requestFocus();\n\t\t\tselectedChatListView.jumpToBottom(true);\n\t\t\tsetUnreadMessages(roomInfoTreeItem, false);\n\t\t}, () -> {\n\t\t\tchatRoomInfoController.setRoomInfo(chatRoomInfo);\n\t\t\tswitchChatContent(roomInfoView, null);\n\t\t\tstatus.setVisible(false);\n\t\t\tsendGroup.setVisible(false);\n\t\t\tselectedChatListView = null;\n\t\t});\n\t\tnicknameCompleter.setUsernameFinder(selectedChatListView);\n\t}\n\n\tprivate boolean isRoomSelected()\n\t{\n\t\treturn selectedRoom != null && selectedRoom.getId() != 0L;\n\t}\n\n\tprivate Optional<TreeItem<RoomHolder>> getSubscribedTreeItem(long roomId)\n\t{\n\t\treturn subscribedRooms.getChildren().stream()\n\t\t\t\t.filter(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().getId() == roomId)\n\t\t\t\t.findFirst();\n\t}\n\n\tprivate Optional<TreeItem<RoomHolder>> getAllTreeItem(long roomId)\n\t{\n\t\treturn Stream.concat(subscribedRooms.getChildren().stream(), Stream.concat(publicRooms.getChildren().stream(), privateRooms.getChildren().stream()))\n\t\t\t\t.filter(roomHolderTreeItem -> roomHolderTreeItem.getValue().getRoomInfo().getId() == roomId)\n\t\t\t\t.findFirst();\n\t}\n\n\tpublic void showMessage(ChatRoomMessage chatRoomMessage)\n\t{\n\t\tif (chatRoomMessage.isEmpty())\n\t\t{\n\t\t\tif (isRoomSelected() && chatRoomMessage.getRoomId() == selectedRoom.getId())\n\t\t\t{\n\t\t\t\ttypingNotification.setText(MessageFormat.format(bundle.getString(\"chat.notification.typing\"), chatRoomMessage.getSenderNickname()));\n\t\t\t\tlastTypingTimeline.playFromStart();\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tperformOnChatListView(chatRoomMessage.getRoomId(), chatListView -> {\n\t\t\t\tif (chatRoomMessage.isOwn())\n\t\t\t\t{\n\t\t\t\t\tchatListView.addOwnMessage(chatRoomMessage);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tchatListView.addUserMessage(chatRoomMessage.getSenderNickname(), chatRoomMessage.getGxsId(), chatRoomMessage.getContent());\n\t\t\t\t\tsetHighlighted(chatRoomMessage.getContent());\n\t\t\t\t}\n\t\t\t});\n\t\t\tgetSubscribedTreeItem(chatRoomMessage.getRoomId()).ifPresent(roomHolderTreeItem -> {\n\t\t\t\tif (isRoomSelected() && selectedRoom.getId() != chatRoomMessage.getRoomId())\n\t\t\t\t{\n\t\t\t\t\tsetUnreadMessages(roomHolderTreeItem, true);\n\t\t\t\t}\n\t\t\t});\n\t\t\tif (isRoomSelected() && chatRoomMessage.getRoomId() == selectedRoom.getId())\n\t\t\t{\n\t\t\t\tlastTypingTimeline.jumpTo(javafx.util.Duration.INDEFINITE);\n\t\t\t}\n\t\t\tunreadService.sendUnreadEvent(UnreadEvent.Element.CHAT_ROOM, true);\n\t\t}\n\t}\n\n\tprivate void setUnreadMessages(TreeItem<RoomHolder> roomHolderTreeItem, boolean unread)\n\t{\n\t\troomHolderTreeItem.getValue().getRoomInfo().setNewMessages(unread);\n\t\troomTree.refresh();\n\t}\n\n\tprivate void setHighlighted(String message)\n\t{\n\t\tif (message.startsWith(nickname) || message.startsWith(\"@\" + nickname) || message.contains(\" \" + nickname))\n\t\t{\n\t\t\tsoundPlayerService.play(SoundType.HIGHLIGHT);\n\t\t\ttrayService.setEventIfIconified();\n\t\t}\n\t}\n\n\tprivate void performOnChatListView(long roomId, Consumer<ChatListView> action)\n\t{\n\t\tsubscribedRooms.getChildren().stream()\n\t\t\t\t.map(this::getChatListViewOrCreate)\n\t\t\t\t.filter(chatListView -> chatListView.getId() == roomId)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(action);\n\t}\n\n\tprivate ChatListView getChatListViewOrCreate(TreeItem<RoomHolder> roomInfoTreeItem)\n\t{\n\t\tvar chatListView = roomInfoTreeItem.getValue().getChatListView();\n\t\tif (chatListView == null)\n\t\t{\n\t\t\tvar chatRoomId = roomInfoTreeItem.getValue().getRoomInfo().getId();\n\t\t\tchatListView = new ChatListView(nickname, chatRoomId, markdownService, uriService, generalClient, imageCache, windowManager, send);\n\t\t\tchatListView.installClearHistoryContextMenu(() -> chatClient.deleteChatRoomBacklog(chatRoomId)\n\t\t\t\t\t.subscribe());\n\t\t\tvar finalChatListView = chatListView;\n\t\t\tchatClient.getChatRoomBacklog(chatRoomId).collectList()\n\t\t\t\t\t.doOnSuccess(backlogs -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert backlogs != null;\n\t\t\t\t\t\tfillBacklog(finalChatListView, backlogs);\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t\troomInfoTreeItem.getValue().setChatListView(chatListView);\n\t\t}\n\t\treturn chatListView;\n\t}\n\n\tprivate void fillBacklog(ChatListView chatListView, List<ChatRoomBacklog> messages)\n\t{\n\t\tmessages.forEach(message -> {\n\t\t\tif (message.gxsId() == null)\n\t\t\t{\n\t\t\t\tchatListView.addOwnMessage(message.created(), message.message());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tchatListView.addUserMessage(message.created(), message.nickname(), message.gxsId(), message.message());\n\t\t\t}\n\t\t});\n\t\tchatListView.jumpToBottom(true);\n\t}\n\n\tprivate void handleInputKeys(KeyEvent event)\n\t{\n\t\tif (TAB_KEY.match(event))\n\t\t{\n\t\t\tnicknameCompleter.complete(send.getTextInputControl().getText(), send.getTextInputControl().getCaretPosition(), s -> {\n\t\t\t\tsend.getTextInputControl().setText(s);\n\t\t\t\tsend.getTextInputControl().positionCaret(s.length());\n\t\t\t});\n\t\t\tevent.consume();\n\t\t\treturn;\n\t\t}\n\n\t\tnicknameCompleter.reset();\n\n\t\tif (PASTE_KEY.match(event))\n\t\t{\n\t\t\tif (handlePaste(send.getTextInputControl()))\n\t\t\t{\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t}\n\t\telse if (COPY_KEY.match(event))\n\t\t{\n\t\t\tif (selectedChatListView != null && selectedChatListView.copy())\n\t\t\t{\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t}\n\t\telse if (CTRL_ENTER.match(event) || SHIFT_ENTER.match(event) && isNotBlank(send.getTextInputControl().getText()))\n\t\t{\n\t\t\tsend.getTextInputControl().insertText(send.getTextInputControl().getCaretPosition(), \"\\n\");\n\t\t\tsendTypingNotificationIfNeeded();\n\t\t\tevent.consume();\n\t\t}\n\t\telse if (ENTER_KEY.match(event) && imagePreview.getImage() != null)\n\t\t{\n\t\t\tsendImage();\n\t\t\tevent.consume();\n\t\t}\n\t\telse if (BACKSPACE_KEY.match(event) && imagePreview.getImage() != null)\n\t\t{\n\t\t\tcancelImage();\n\t\t\tevent.consume();\n\t\t}\n\t\telse if (event.getCode() == KeyCode.ENTER)\n\t\t{\n\t\t\tif (isRoomSelected() && isNotBlank(send.getTextInputControl().getText()))\n\t\t\t{\n\t\t\t\tsendChatMessage(send.getTextInputControl().getText());\n\t\t\t\tsend.clear();\n\t\t\t\tlastTypingNotification = Instant.EPOCH;\n\t\t\t}\n\t\t\tevent.consume();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsendTypingNotificationIfNeeded();\n\t\t}\n\t}\n\n\tprivate void sendTypingNotificationIfNeeded()\n\t{\n\t\tvar now = Instant.now();\n\t\tif (Duration.between(lastTypingNotification, now).compareTo(TYPING_NOTIFICATION_DELAY.minusSeconds(1)) > 0)\n\t\t{\n\t\t\tvar chatMessage = new ChatMessage();\n\t\t\tmessageClient.sendToChatRoom(selectedRoom.getId(), chatMessage);\n\t\t\tlastTypingNotification = now;\n\t\t}\n\t}\n\n\tprivate boolean handlePaste(TextInputControl textInputControl)\n\t{\n\t\tvar object = ClipboardUtils.getSupportedObjectFromClipboard();\n\t\treturn switch (object)\n\t\t{\n\t\t\tcase Image image ->\n\t\t\t{\n\t\t\t\tsetPreviewImage(image);\n\t\t\t\tyield true;\n\t\t\t}\n\t\t\tcase String string ->\n\t\t\t{\n\t\t\t\tTextInputControlUtils.pasteGuessedContent(textInputControl, string);\n\t\t\t\tyield true;\n\t\t\t}\n\t\t\tcase null, default -> false;\n\t\t};\n\t}\n\n\tprivate void sendImage()\n\t{\n\t\tsendChatMessage(\"<img src=\\\"\" + ImageUtils.writeImage(SwingFXUtils.fromFXImage(imagePreview.getImage(), null), MESSAGE_MAXIMUM_SIZE) + \"\\\"/>\");\n\n\t\tresetPreviewImage();\n\t\tjumpToBottom();\n\t}\n\n\tprivate void sendStickerToMessage(BufferedImage image)\n\t{\n\t\timage = ImageUtils.limitMaximumImageSize(image, STICKER_WIDTH_MAX * STICKER_HEIGHT_MAX);\n\t\tsendChatMessage(\"<img src=\\\"\" + ImageUtils.writeImage(image, MESSAGE_MAXIMUM_SIZE) + \"\\\"/>\");\n\t}\n\n\tprivate void cancelImage()\n\t{\n\t\tresetPreviewImage();\n\t}\n\n\tprivate void setPreviewImage(Image image)\n\t{\n\t\timagePreview.setImage(image);\n\n\t\tImageViewUtils.limitMaximumImageSize(imagePreview, PREVIEW_IMAGE_WIDTH_MAX * PREVIEW_IMAGE_HEIGHT_MAX);\n\n\t\tsetPreviewGroupVisibility(true);\n\t}\n\n\t/**\n\t * Resets the size so that smaller images aren't magnified.\n\t */\n\tprivate void resetPreviewImage()\n\t{\n\t\timagePreview.setImage(null);\n\t\tsetPreviewGroupVisibility(false);\n\t\timagePreview.setFitWidth(0);\n\t\timagePreview.setFitHeight(0);\n\t}\n\n\tprivate void sendChatMessage(String message)\n\t{\n\t\tvar chatMessage = new ChatMessage(ChatCommand.parseCommands(message));\n\t\tmessageClient.sendToChatRoom(selectedRoom.getId(), chatMessage);\n\t}\n\n\tprivate void setPreviewGroupVisibility(boolean visible)\n\t{\n\t\tUiUtils.setPresent(previewGroup, visible);\n\t}\n\n\tpublic void openInvite(long chatRoomId, ChatRoomInviteEvent event)\n\t{\n\t\tPlatform.runLater(() -> UiUtils.showAlertConfirm(MessageFormat.format(bundle.getString(\"chat.room.invite.request\"), event.getLocationIdentifier(), event.getRoomName(), event.getRoomTopic()),\n\t\t\t\t() -> chatClient.joinChatRoom(chatRoomId).subscribe())\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/PeerHolder.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.model.profile.Profile;\n\nclass PeerHolder\n{\n\tprivate Profile profile;\n\tprivate Location location;\n\n\tpublic PeerHolder()\n\t{\n\n\t}\n\n\tpublic PeerHolder(Profile profile)\n\t{\n\t\tthis.profile = profile;\n\t}\n\n\tpublic PeerHolder(Profile profile, Location location)\n\t{\n\t\tthis.profile = profile;\n\t\tthis.location = location;\n\t}\n\n\tpublic Profile getProfile()\n\t{\n\t\treturn profile;\n\t}\n\n\tpublic Location getLocation()\n\t{\n\t\treturn location;\n\t}\n\n\tpublic void setLocation(Location location)\n\t{\n\t\tthis.location = location;\n\t}\n\n\tpublic boolean hasLocation()\n\t{\n\t\treturn location != null;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn hasLocation() ? getLocation().getName() : getProfile().getName();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/chat/RoomHolder.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.message.chat.ChatRoomInfo;\n\npublic class RoomHolder\n{\n\tprivate ChatListView chatListView;\n\tprivate final ChatRoomInfo chatRoomInfo;\n\n\tpublic RoomHolder(String name)\n\t{\n\t\tchatRoomInfo = new ChatRoomInfo(name);\n\t}\n\n\tpublic RoomHolder(ChatRoomInfo chatRoomInfo)\n\t{\n\t\tthis.chatRoomInfo = chatRoomInfo;\n\t}\n\n\tpublic RoomHolder()\n\t{\n\t\tchatRoomInfo = new ChatRoomInfo(\"\");\n\t}\n\n\tpublic void setChatListView(ChatListView chatListView)\n\t{\n\t\tthis.chatListView = chatListView;\n\t}\n\n\tpublic void clearChatListView()\n\t{\n\t\tchatListView = null;\n\t}\n\n\tpublic ChatListView getChatListView()\n\t{\n\t\treturn chatListView;\n\t}\n\n\tpublic ChatRoomInfo getRoomInfo()\n\t{\n\t\treturn chatRoomInfo;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn chatRoomInfo.getName();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/common/GxsGroup.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.common;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic interface GxsGroup\n{\n\t/**\n\t * Checks if it's a real Gxs Group, that means not a tree directory.\n\t *\n\t * @return true if it's a real gxs group\n\t */\n\tboolean isReal();\n\n\tlong getId();\n\n\tGxsId getGxsId();\n\n\tString getName();\n\n\tString getDescription();\n\n\t/**\n\t * Checks if the group comes from other people than us.\n\t * @return true if it's an external group, that is now a group created by us\n\t */\n\tboolean isExternal();\n\n\tint getVisibleMessageCount();\n\n\tInstant getLastActivity();\n\n\tboolean isSubscribed();\n\n\tvoid setSubscribed(boolean subscribed);\n\n\tboolean hasNewMessages();\n\n\tvoid setUnreadCount(int unreadCount);\n\n\tvoid addUnreadCount(int value);\n\n\tvoid subtractUnreadCount(int value);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/common/GxsGroupCellCount.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.common;\n\nimport javafx.scene.control.TreeTableCell;\n\npublic class GxsGroupCellCount<T> extends TreeTableCell<T, Integer>\n{\n\t@Override\n\tprotected void updateItem(Integer value, boolean empty)\n\t{\n\t\tsuper.updateItem(value, empty);\n\n\t\tif (empty || value == null)\n\t\t{\n\t\t\tsetText(null);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (value == 0)\n\t\t\t{\n\t\t\t\tsetText(null);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsetText(value.toString());\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/common/GxsGroupTreeTableAction.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.common;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\n\npublic interface GxsGroupTreeTableAction<T>\n{\n\tvoid onSubscribeToGroup(T group);\n\n\tvoid onUnsubscribeFromGroup(T group);\n\n\tvoid onSelectSubscribedGroup(T group);\n\n\tvoid onSelectUnsubscribedGroup(T group);\n\n\tvoid onUnselectGroup();\n\n\tvoid onEditGroup(T group);\n\n\tvoid onCopyGroupLink(T group);\n\n\tvoid onOpenUrl(GxsId gxsId, MsgId msgId);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/common/GxsGroupTreeTableView.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.common;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.client.GxsGroupClient;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.beans.property.ReadOnlyBooleanProperty;\nimport javafx.beans.property.ReadOnlyBooleanWrapper;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.collections.ObservableList;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.control.*;\nimport javafx.scene.control.cell.TreeItemPropertyValueFactory;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignE;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignL;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignS;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.function.Supplier;\nimport java.util.stream.Stream;\n\n/**\n * A listview that keeps a list of GXS groups and allows to switch to them. Also known\n * as a sidebar.\n *\n * @param <T>\n */\npublic class GxsGroupTreeTableView<T extends GxsGroup> extends TreeTableView<T>\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(GxsGroupTreeTableView.class);\n\n\tprivate static final String SUBSCRIBE_MENU_ID = \"subscribe\";\n\tprivate static final String UNSUBSCRIBE_MENU_ID = \"unsubscribe\";\n\tprivate static final String MARK_AS_READ_MENU_ID = \"mark-as-read\";\n\tprivate static final String MARK_AS_UNREAD_MENU_ID = \"mark-as-unread\";\n\tprivate static final String COPY_LINK_MENU_ID = \"copyLink\";\n\tprivate static final String EDIT_MENU_ID = \"edit\";\n\n\tprivate static final String OPEN_OWN = \"OpenOwn\";\n\tprivate static final String OPEN_SUBSCRIBED = \"OpenSubscribed\";\n\tprivate static final String OPEN_POPULAR = \"OpenPopular\";\n\tprivate static final String OPEN_OTHER = \"OpenOther\";\n\n\t@FXML\n\tprivate TreeTableColumn<T, T> groupNameColumn;\n\n\t@FXML\n\tprivate TreeTableColumn<T, Integer> groupCountColumn;\n\n\tprivate GxsGroupTreeTableAction<T> action;\n\n\tprivate TreeItem<T> ownGroups;\n\tprivate TreeItem<T> subscribedGroups;\n\tprivate TreeItem<T> popularGroups;\n\tprivate TreeItem<T> otherGroups;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tprivate GxsGroupClient<T> groupClient;\n\n\tprivate T selectedGroup;\n\n\tprivate final ReadOnlyBooleanWrapper unread = new ReadOnlyBooleanWrapper();\n\n\tpublic GxsGroupTreeTableView()\n\t{\n\t\tvar loader = new FXMLLoader(GxsGroupTreeTableView.class.getResource(\"/view/custom/gxs_group_tree_table_view.fxml\"), bundle);\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic void initialize(String preferenceNodeName, GxsGroupClient<T> groupClient, Function<String, T> groupCreator, Supplier<TreeTableCell<T, T>> cellCreator, GxsGroupTreeTableAction<T> action)\n\t{\n\t\tthis.action = action;\n\t\tthis.groupClient = groupClient;\n\n\t\townGroups = new TreeItem<>(groupCreator.apply(bundle.getString(\"own\")));\n\t\tsubscribedGroups = new TreeItem<>(groupCreator.apply(bundle.getString(\"subscribed\")));\n\t\tpopularGroups = new TreeItem<>(groupCreator.apply(bundle.getString(\"gxs-group.tree.popular\")));\n\t\totherGroups = new TreeItem<>(groupCreator.apply(bundle.getString(\"gxs-group.tree.other\")));\n\n\t\tvar root = new TreeItem<>(groupCreator.apply(\"\"));\n\t\t//noinspection unchecked\n\t\troot.getChildren().addAll(ownGroups, subscribedGroups, popularGroups, otherGroups);\n\t\troot.setExpanded(true);\n\t\tsetRoot(root);\n\t\tsetShowRoot(false);\n\t\tgroupNameColumn.setCellFactory(_ -> cellCreator.get());\n\t\tgroupNameColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue()));\n\t\tgroupCountColumn.setCellFactory(_ -> new GxsGroupCellCount<>());\n\t\tgroupCountColumn.setCellValueFactory(new TreeItemPropertyValueFactory<>(\"unreadCount\"));\n\t\tcreateTreeContextMenu();\n\n\t\t// We need Platform.runLater() because when an entry is moved, the selection can change\n\t\tgetSelectionModel().selectedItemProperty()\n\t\t\t\t.addListener((_, _, newValue) -> Platform.runLater(() -> {\n\t\t\t\t\tselectedGroup = newValue != null ? newValue.getValue() : null;\n\n\t\t\t\t\tif (selectedGroup == null)\n\t\t\t\t\t{\n\t\t\t\t\t\taction.onUnselectGroup();\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tgetSubscribedGroups()\n\t\t\t\t\t\t\t\t.filter(forumGroupTreeItem -> forumGroupTreeItem.getValue().getId() == selectedGroup.getId())\n\t\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t\t.ifPresentOrElse(_ -> action.onSelectSubscribedGroup(selectedGroup),\n\t\t\t\t\t\t\t\t\t\t() -> action.onSelectUnsubscribedGroup(selectedGroup));\n\t\t\t\t\t}\n\t\t\t\t}));\n\n\t\tUiUtils.setOnPrimaryMouseDoubleClicked(this, _ -> {\n\t\t\tif (isGroupSelected())\n\t\t\t{\n\t\t\t\tsubscribeToGroup(selectedGroup);\n\t\t\t}\n\t\t});\n\n\t\tsetupTrees(preferenceNodeName);\n\n\t\tgetGroups();\n\t}\n\n\tpublic ReadOnlyBooleanProperty unreadProperty()\n\t{\n\t\treturn unread.getReadOnlyProperty();\n\t}\n\n\tpublic boolean isUnread()\n\t{\n\t\treturn unread.get();\n\t}\n\n\tpublic long getSelectedGroupId()\n\t{\n\t\tif (selectedGroup == null)\n\t\t{\n\t\t\tlog.error(\"getSelectedGroupId() has been called while there's no selected group\");\n\t\t\treturn 0L;\n\t\t}\n\t\treturn selectedGroup.getId();\n\t}\n\n\tpublic GxsId getSelectedGroupGxsId()\n\t{\n\t\tif (selectedGroup == null)\n\t\t{\n\t\t\tlog.error(\"getSelectedGroupGxsId() has been called while there's no selected group\");\n\t\t\treturn null;\n\t\t}\n\t\treturn selectedGroup.getGxsId();\n\t}\n\n\tprivate Stream<TreeItem<T>> getAllGroups()\n\t{\n\t\treturn Stream.of(ownGroups.getChildren().stream(), // Concat all streams\n\t\t\t\tsubscribedGroups.getChildren().stream(),\n\t\t\t\tpopularGroups.getChildren().stream(),\n\t\t\t\totherGroups.getChildren().stream()\n\t\t).reduce(Stream.empty(), Stream::concat);\n\t}\n\n\tpublic void refreshUnreadCount(long groupId)\n\t{\n\t\tgetSubscribedGroups()\n\t\t\t\t.filter(groupTreeItem -> groupTreeItem.getValue().getId() == groupId)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(groupTreeItem -> groupClient.getUnreadCount(groupTreeItem.getValue().getId())\n\t\t\t\t\t\t.doOnSuccess(count -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert count != null;\n\t\t\t\t\t\t\tgroupTreeItem.getValue().setUnreadCount(count);\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.doFinally(_ -> Platform.runLater(this::refreshUnreadCount))\n\t\t\t\t\t\t.subscribe());\n\t}\n\n\tpublic void refreshUnreadCount(Set<GxsId> groups)\n\t{\n\t\tgroups.forEach(gxsId -> getSubscribedTreeItemByGxsId(gxsId).ifPresent(groupTreeItem -> groupClient.getUnreadCount(groupTreeItem.getValue().getId())\n\t\t\t\t.doOnSuccess(count -> Platform.runLater(() -> {\n\t\t\t\t\tassert count != null;\n\t\t\t\t\tgroupTreeItem.getValue().setUnreadCount(count);\n\t\t\t\t}))\n\t\t\t\t.doFinally(_ -> Platform.runLater(this::refreshUnreadCount))\n\t\t\t\t.subscribe()));\n\t}\n\n\tpublic void addGroups(List<T> groups)\n\t{\n\t\tgroups.forEach(group -> {\n\t\t\tif (!group.isExternal())\n\t\t\t{\n\t\t\t\taddOrUpdate(ownGroups, group);\n\t\t\t}\n\t\t\telse if (group.isSubscribed())\n\t\t\t{\n\t\t\t\taddOrUpdate(subscribedGroups, group);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\taddOrUpdate(popularGroups, group);\n\t\t\t}\n\t\t});\n\t\tupdateGroupsUnreadCount(groups);\n\t}\n\n\tpublic void setUnreadCount(long groupId, boolean read)\n\t{\n\t\tif (selectedGroup.getId() == groupId)\n\t\t{\n\t\t\tselectedGroup.addUnreadCount(read ? -1 : 1);\n\t\t\trefreshUnreadCount();\n\t\t}\n\t}\n\n\tpublic boolean openUrl(GxsId groupGxsId, MsgId msgId)\n\t{\n\t\tvar treeItem = getAllGroups()\n\t\t\t\t.filter(groupTreeItem -> groupTreeItem.getValue().getGxsId().equals(groupGxsId))\n\t\t\t\t.findFirst()\n\t\t\t\t.orElse(null);\n\n\t\tif (treeItem == null)\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\taction.onOpenUrl(groupGxsId, msgId);\n\n\t\tif (treeItem.getValue() != selectedGroup)\n\t\t{\n\t\t\tPlatform.runLater(() -> getSelectionModel().select(treeItem));\n\t\t}\n\t\treturn true;\n\t}\n\n\tprivate Stream<TreeItem<T>> getSubscribedGroups()\n\t{\n\t\treturn Stream.concat(subscribedGroups.getChildren().stream(), ownGroups.getChildren().stream());\n\t}\n\n\tprivate void addOrUpdate(TreeItem<T> parent, T group)\n\t{\n\t\tvar tree = parent.getChildren();\n\n\t\ttree.stream()\n\t\t\t\t.filter(existingTree -> existingTree.getValue().getId() == group.getId())\n\t\t\t\t.findAny().ifPresentOrElse(found -> found.setValue(group),\n\t\t\t\t\t\t() -> {\n\t\t\t\t\t\t\ttree.add(new TreeItem<>(group));\n\t\t\t\t\t\t\tparent.getValue().addUnreadCount(1);\n\t\t\t\t\t\t\tsortByName(tree);\n\t\t\t\t\t\t\tremoveFromOthers(parent, group);\n\t\t\t\t\t\t});\n\t}\n\n\tprivate void subscribeToGroup(T group)\n\t{\n\t\tvar alreadySubscribed = subscribedGroups.getChildren().stream()\n\t\t\t\t.anyMatch(holderTreeItem -> holderTreeItem.getValue().getId() == group.getId());\n\n\t\tif (!alreadySubscribed)\n\t\t{\n\t\t\tgroupClient.subscribeToGroup(group.getId())\n\t\t\t\t\t.doOnSuccess(_ -> {\n\t\t\t\t\t\tgroup.setSubscribed(true);\n\t\t\t\t\t\taddOrUpdate(subscribedGroups, group);\n\t\t\t\t\t})\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tprivate void unsubscribeFromGroup(T group)\n\t{\n\t\tsubscribedGroups.getChildren().stream()\n\t\t\t\t.filter(holderTreeItem -> holderTreeItem.getValue().getId() == group.getId())\n\t\t\t\t.findAny()\n\t\t\t\t.ifPresent(_ -> groupClient.unsubscribeFromGroup(group.getId())\n\t\t\t\t\t\t.doOnSuccess(_ -> {\n\t\t\t\t\t\t\tgroup.setSubscribed(false);\n\t\t\t\t\t\t\taddOrUpdate(popularGroups, group);\n\t\t\t\t\t\t}) // XXX: wrong, could be something else then \"others\"\n\t\t\t\t\t\t.subscribe());\n\t}\n\n\tprivate void markAllAsRead(T group, boolean read)\n\t{\n\t\tgroupClient.setGroupMessagesReadState(group.getId(), read)\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void updateGroupsUnreadCount(List<T> groups)\n\t{\n\t\tgroups.forEach(group -> groupClient.getUnreadCount(group.getId())\n\t\t\t\t.doOnSuccess(unreadCount -> {\n\t\t\t\t\tassert unreadCount != null;\n\t\t\t\t\tPlatform.runLater(() -> getSubscribedTreeItemByGxsId(group.getGxsId())\n\t\t\t\t\t\t\t.ifPresent(groupTreeItem -> groupTreeItem.getValue().setUnreadCount(unreadCount)));\n\t\t\t\t})\n\t\t\t\t.doFinally(_ -> Platform.runLater(this::refreshUnreadCount))\n\t\t\t\t.subscribe());\n\t}\n\n\tprivate Optional<TreeItem<T>> getSubscribedTreeItemByGxsId(GxsId gxsId)\n\t{\n\t\treturn Stream.concat(subscribedGroups.getChildren().stream(), ownGroups.getChildren().stream())\n\t\t\t\t.filter(groupTreeItem -> groupTreeItem.getValue().getGxsId().equals(gxsId))\n\t\t\t\t.findFirst();\n\t}\n\n\tprivate void refreshUnreadCount()\n\t{\n\t\tboolean hasUnreadMessages = hasUnreadMessages();\n\t\tunread.set(hasUnreadMessages);\n\t}\n\n\tprivate boolean hasUnreadMessages()\n\t{\n\t\treturn hasUnreadMessagesRecursive(getRoot());\n\t}\n\n\tprivate boolean hasUnreadMessagesRecursive(TreeItem<T> item)\n\t{\n\t\tvar group = item.getValue();\n\t\tif (group != null && group.hasNewMessages())\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tfor (TreeItem<T> child : item.getChildren())\n\t\t{\n\t\t\tif (hasUnreadMessagesRecursive(child))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate void sortByName(ObservableList<TreeItem<T>> children)\n\t{\n\t\tchildren.sort((o1, o2) -> o1.getValue().getName().compareToIgnoreCase(o2.getValue().getName()));\n\t}\n\n\tprivate void removeFromOthers(TreeItem<T> parent, T group)\n\t{\n\t\tvar removalList = new ArrayList<>(List.of(ownGroups, subscribedGroups, popularGroups, otherGroups));\n\t\tremovalList.remove(parent);\n\n\t\tremovalList.forEach(treeItems -> treeItems.getChildren().stream()\n\t\t\t\t.filter(boardHolderTreeItem -> boardHolderTreeItem.getValue().getId() == group.getId())\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(boardGroupTreeItem -> {\n\t\t\t\t\ttreeItems.getChildren().remove(boardGroupTreeItem);\n\t\t\t\t\ttreeItems.getValue().subtractUnreadCount(1);\n\t\t\t\t}));\n\t}\n\n\tprivate void setupTrees(String nodeName)\n\t{\n\t\tvar node = PreferenceUtils.getPreferences().node(nodeName);\n\t\townGroups.setExpanded(node.getBoolean(OPEN_OWN, false));\n\t\tsubscribedGroups.setExpanded(node.getBoolean(OPEN_SUBSCRIBED, false));\n\t\tpopularGroups.setExpanded(node.getBoolean(OPEN_POPULAR, false));\n\t\totherGroups.setExpanded(node.getBoolean(OPEN_OTHER, false));\n\n\t\townGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_OWN, newValue));\n\t\tsubscribedGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_SUBSCRIBED, newValue));\n\t\tpopularGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_POPULAR, newValue));\n\t\totherGroups.expandedProperty().addListener((_, _, newValue) -> node.putBoolean(OPEN_OTHER, newValue));\n\t}\n\n\tprivate void getGroups()\n\t{\n\t\tgroupClient.getGroups().collectList()\n\t\t\t\t.doOnSuccess(groups -> {\n\t\t\t\t\tassert groups != null;\n\t\t\t\t\taddGroups(groups);\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate boolean isGroupSelected()\n\t{\n\t\treturn selectedGroup != null && selectedGroup.isReal();\n\t}\n\n\tprivate void createTreeContextMenu()\n\t{\n\t\tvar editItem = new MenuItem(\"Edit\");\n\t\teditItem.setId(EDIT_MENU_ID);\n\t\teditItem.setGraphic(new FontIcon(MaterialDesignS.SQUARE_EDIT_OUTLINE));\n\t\teditItem.setOnAction(event -> {\n\t\t\t//noinspection unchecked\n\t\t\tvar group = ((TreeItem<T>) event.getSource()).getValue();\n\t\t\taction.onEditGroup(group);\n\t\t});\n\n\t\tvar subscribeItem = new MenuItem(bundle.getString(\"gxs-group.tree.subscribe\"));\n\t\tsubscribeItem.setId(SUBSCRIBE_MENU_ID);\n\t\tsubscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_ENTER));\n\t\tsubscribeItem.setOnAction(event -> {\n\t\t\t//noinspection unchecked\n\t\t\tvar group = ((TreeItem<T>) event.getSource()).getValue();\n\t\t\tsubscribeToGroup(group);\n\t\t\taction.onSubscribeToGroup(group);\n\t\t});\n\n\t\tvar unsubscribeItem = new MenuItem(bundle.getString(\"gxs-group.tree.unsubscribe\"));\n\t\tunsubscribeItem.setId(UNSUBSCRIBE_MENU_ID);\n\t\tunsubscribeItem.setGraphic(new FontIcon(MaterialDesignL.LOCATION_EXIT));\n\t\tunsubscribeItem.setOnAction(event -> {\n\t\t\t//noinspection unchecked\n\t\t\tvar group = ((TreeItem<T>) event.getSource()).getValue();\n\t\t\tunsubscribeFromGroup(group);\n\t\t\taction.onUnsubscribeFromGroup(group);\n\t\t});\n\n\t\tvar markAllAsReadItem = new MenuItem(\"Mark All as Read\");\n\t\tmarkAllAsReadItem.setId(MARK_AS_READ_MENU_ID);\n\t\tmarkAllAsReadItem.setGraphic(new FontIcon(MaterialDesignE.EMAIL));\n\t\tmarkAllAsReadItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var group = ((TreeItem<T>) event.getSource()).getValue();\n\t\t\tmarkAllAsRead(group, true);\n\t\t});\n\n\t\tvar markAllAsUnReadItem = new MenuItem(\"Mark All as Unread\");\n\t\tmarkAllAsUnReadItem.setId(MARK_AS_UNREAD_MENU_ID);\n\t\tmarkAllAsUnReadItem.setGraphic(new FontIcon(MaterialDesignE.EMAIL_MARK_AS_UNREAD));\n\t\tmarkAllAsUnReadItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var group = ((TreeItem<T>) event.getSource()).getValue();\n\t\t\tmarkAllAsRead(group, false);\n\t\t});\n\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy-link\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\t//noinspection unchecked\n\t\tcopyLinkItem.setOnAction(event -> action.onCopyGroupLink(((TreeItem<T>) event.getSource()).getValue()));\n\n\t\tvar optionalSeparatorItem = new SeparatorMenuItem();\n\n\t\tvar xContextMenu = new XContextMenu<TreeItem<T>>(subscribeItem, unsubscribeItem, editItem, optionalSeparatorItem, markAllAsReadItem, markAllAsUnReadItem, new SeparatorMenuItem(), copyLinkItem);\n\t\txContextMenu.addToNode(this);\n\t\txContextMenu.setOnShowing((contextMenu, treeItem) -> {\n\t\t\tif (treeItem == null)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tvar value = treeItem.getValue();\n\n\t\t\tif (!value.isReal())\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> SUBSCRIBE_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> {\n\t\t\t\t\t\tmenuItem.setDisable(value.isSubscribed());\n\t\t\t\t\t\tmenuItem.setVisible(value.isExternal());\n\t\t\t\t\t});\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> UNSUBSCRIBE_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> {\n\t\t\t\t\t\tmenuItem.setDisable(!treeItem.getValue().isSubscribed());\n\t\t\t\t\t\tmenuItem.setVisible(value.isExternal());\n\t\t\t\t\t});\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> EDIT_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setVisible(!value.isExternal()));\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> menuItem.equals(optionalSeparatorItem))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setVisible(!value.isExternal()));\n\t\t\treturn true;\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/common/GxsMessage.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.common;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.time.Instant;\n\npublic interface GxsMessage\n{\n\tlong getId();\n\n\tGxsId getGxsId();\n\n\tlong getOriginalId();\n\n\tInstant getPublished();\n\n\tboolean isRead();\n\n\tvoid setRead(boolean read);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/AvailabilityCellStatus.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.location.Availability;\nimport javafx.scene.control.TableCell;\nimport org.kordamp.ikonli.javafx.FontIcon;\n\nclass AvailabilityCellStatus<T> extends TableCell<T, Availability>\n{\n\t@Override\n\tprotected void updateItem(Availability item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetGraphic(empty ? null : AvailabilityCellUtil.updateAvailability((FontIcon) getGraphic(), item));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/AvailabilityCellUtil.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.location.Availability;\nimport org.kordamp.ikonli.javafx.FontIcon;\n\nfinal class AvailabilityCellUtil\n{\n\tprivate AvailabilityCellUtil()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static FontIcon updateAvailability(FontIcon icon, Availability availability)\n\t{\n\t\tif (icon == null)\n\t\t{\n\t\t\ticon = new FontIcon();\n\t\t}\n\t\ticon.getStyleClass().removeAll(\"success\", \"warning\", \"danger\");\n\t\tswitch (availability)\n\t\t{\n\t\t\tcase AVAILABLE ->\n\t\t\t{\n\t\t\t\ticon.setIconLiteral(\"mdi2c-circle\");\n\t\t\t\ticon.getStyleClass().add(\"success\");\n\t\t\t\ticon.setVisible(true);\n\t\t\t}\n\t\t\tcase AWAY ->\n\t\t\t{\n\t\t\t\ticon.setIconLiteral(\"mdi2c-clock-time-two\");\n\t\t\t\ticon.getStyleClass().add(\"warning\");\n\t\t\t\ticon.setVisible(true);\n\t\t\t}\n\t\t\tcase BUSY ->\n\t\t\t{\n\t\t\t\ticon.setIconLiteral(\"mdi2m-minus-circle\");\n\t\t\t\ticon.getStyleClass().add(\"danger\");\n\t\t\t\ticon.setVisible(true);\n\t\t\t}\n\t\t\tcase OFFLINE -> icon.setVisible(false);\n\t\t}\n\t\treturn icon;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/AvailabilityTreeCellStatus.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.location.Availability;\nimport javafx.scene.control.TreeTableCell;\nimport org.kordamp.ikonli.javafx.FontIcon;\n\nclass AvailabilityTreeCellStatus<T> extends TreeTableCell<T, Availability>\n{\n\t@Override\n\tprotected void updateItem(Availability item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetGraphic(empty ? null : AvailabilityCellUtil.updateAvailability((FontIcon) getGraphic(), item));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/ContactCellName.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.support.contact.ContactUtils;\nimport javafx.scene.control.TreeTableCell;\n\nimport static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID;\n\nclass ContactCellName extends TreeTableCell<Contact, Contact>\n{\n\tprivate static final int CONTACT_SIZE = 32;\n\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\n\tpublic ContactCellName(GeneralClient generalClient, ImageCache imageCache)\n\t{\n\t\tsuper();\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t}\n\n\t@Override\n\tprotected void updateItem(Contact item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.name());\n\t\tsetGraphic(empty ? null : updateContact((AsyncImageView) getGraphic(), item));\n\t}\n\n\tprivate AsyncImageView updateContact(AsyncImageView asyncImageView, Contact contact)\n\t{\n\t\tif (asyncImageView == null)\n\t\t{\n\t\t\tasyncImageView = new AsyncImageView(\n\t\t\t\t\turl -> generalClient.getImage(url).block(),\n\t\t\t\t\timageCache);\n\t\t\tasyncImageView.setFitWidth(CONTACT_SIZE);\n\t\t\tasyncImageView.setFitHeight(CONTACT_SIZE);\n\t\t}\n\n\t\tasyncImageView.setUrl(ContactUtils.getIdentityImageUrl(contact));\n\n\t\tif (contact.profileId() == OWN_PROFILE_ID)\n\t\t{\n\t\t\tsetStyle(\"-fx-font-weight: bold\");\n\t\t}\n\t\telse if (!contact.accepted())\n\t\t{\n\t\t\tsetStyle(\"-fx-text-fill: -color-fg-subtle\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetStyle(\"\");\n\t\t}\n\t\treturn asyncImageView;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/ContactFilter.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.rest.contact.Contact;\nimport javafx.collections.transformation.FilteredList;\nimport javafx.scene.control.TreeItem;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.Locale;\nimport java.util.function.Predicate;\n\nclass ContactFilter implements Predicate<TreeItem<Contact>>\n{\n\tprivate final FilteredList<TreeItem<Contact>> filteredList;\n\n\tprivate boolean showAllContacts = true;\n\tprivate String nameFilter;\n\n\tpublic ContactFilter(FilteredList<TreeItem<Contact>> filteredList)\n\t{\n\t\tthis.filteredList = filteredList;\n\t}\n\n\tpublic void setShowAllContacts(boolean showAllContacts)\n\t{\n\t\tthis.showAllContacts = showAllContacts;\n\t\tchangePredicate();\n\t}\n\n\tpublic void setNameFilter(String filter)\n\t{\n\t\tnameFilter = filter;\n\t\tchangePredicate();\n\t}\n\n\t/**\n\t * Forces a change of predicate, otherwise the property will think we're the same.\n\t */\n\tprivate void changePredicate()\n\t{\n\t\tfilteredList.setPredicate(null);\n\t\tfilteredList.setPredicate(this);\n\t}\n\n\t@Override\n\tpublic boolean test(TreeItem<Contact> contact)\n\t{\n\t\tif (StringUtils.isNotEmpty(nameFilter))\n\t\t{\n\t\t\t// When searching, show all contacts\n\t\t\treturn contact.getValue().name().toLowerCase(Locale.ROOT).contains(nameFilter.toLowerCase(Locale.ROOT));\n\t\t}\n\t\treturn showAllContacts || contact.getValue().accepted();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/ContactViewController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport atlantafx.base.controls.CustomTextField;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.protocol.HostPort;\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.rest.notification.contact.AddOrUpdateContacts;\nimport io.xeres.common.rest.notification.contact.RemoveContacts;\nimport io.xeres.common.rest.profile.ProfileKeyAttributes;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.custom.ImageSelectorView;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.model.connection.Connection;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contact.ContactUtils;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.uri.IdentityUri;\nimport io.xeres.ui.support.uri.ProfileUri;\nimport io.xeres.ui.support.util.*;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.ConditionalFeature;\nimport javafx.application.Platform;\nimport javafx.beans.InvalidationListener;\nimport javafx.beans.Observable;\nimport javafx.beans.binding.Bindings;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.property.SimpleStringProperty;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ListChangeListener;\nimport javafx.collections.ObservableList;\nimport javafx.collections.transformation.FilteredList;\nimport javafx.collections.transformation.SortedList;\nimport javafx.event.ActionEvent;\nimport javafx.fxml.FXML;\nimport javafx.scene.Cursor;\nimport javafx.scene.control.*;\nimport javafx.scene.control.cell.PropertyValueFactory;\nimport javafx.scene.effect.DropShadow;\nimport javafx.scene.layout.GridPane;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.layout.VBox;\nimport javafx.scene.paint.Color;\nimport javafx.scene.paint.ImagePattern;\nimport javafx.scene.shape.Circle;\nimport javafx.stage.FileChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignA;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignC;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignL;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignM;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.reactive.function.client.WebClientResponseException;\nimport reactor.core.Disposable;\n\nimport java.text.MessageFormat;\nimport java.util.*;\n\nimport static io.xeres.common.dto.identity.IdentityConstants.NO_IDENTITY_ID;\nimport static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID;\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\nimport static io.xeres.common.dto.profile.ProfileConstants.NO_PROFILE_ID;\nimport static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID;\nimport static io.xeres.ui.support.preference.PreferenceUtils.CONTACTS;\nimport static io.xeres.ui.support.util.DateUtils.DATE_TIME_FORMAT;\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\nimport static javafx.scene.control.Alert.AlertType.WARNING;\n\n@Component\n@FxmlView(value = \"/view/contact/contact_view.fxml\")\npublic class ContactViewController implements Controller\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ContactViewController.class);\n\n\tprivate static final String SHOW_ALL_CONTACTS = \"ShowAllContacts\";\n\n\tprivate static final String CHAT_MENU_ID = \"chat\";\n\tprivate static final String DISTANT_CHAT_MENU_ID = \"distant-chat\";\n\tprivate static final String CONNECT_MENU_ID = \"connect\";\n\tprivate static final String DELETE_MENU_ID = \"delete\";\n\tprivate static final String COPY_LINK_MENU_ID = \"copyLink\";\n\n\tprivate final ConfigClient configClient;\n\tprivate final ConnectionClient connectionClient;\n\n\tprivate enum Information\n\t{\n\t\tPROFILE,\n\t\tIDENTITY,\n\t\tMERGED\n\t}\n\n\t@FXML\n\tprivate TreeTableView<Contact> contactTreeTableView;\n\n\t@FXML\n\tprivate TreeTableColumn<Contact, Contact> contactTreeTableNameColumn;\n\n\t@FXML\n\tprivate TreeTableColumn<Contact, Availability> contactTreeTablePresenceColumn;\n\n\t@FXML\n\tprivate CustomTextField searchTextField;\n\n\t@FXML\n\tprivate ImageSelectorView contactImageSelectorView;\n\n\t@FXML\n\tprivate AsyncImageView ownContactImageView;\n\n\t@FXML\n\tprivate Circle ownContactCircle;\n\n\t@FXML\n\tprivate Circle ownContactState;\n\n\t@FXML\n\tprivate Label ownContactName;\n\n\t@FXML\n\tprivate HBox ownContactGroup;\n\n\t@FXML\n\tprivate Label nameLabel;\n\n\t@FXML\n\tprivate Label idLabel;\n\n\t@FXML\n\tprivate Label typeLabel;\n\n\t@FXML\n\tprivate Label createdOrUpdated;\n\n\t@FXML\n\tprivate Label createdLabel;\n\n\t@FXML\n\tprivate Label trustLabel;\n\n\t@FXML\n\tprivate ChoiceBox<Trust> trust;\n\n\t@FXML\n\tprivate HBox detailsHeader;\n\n\t@FXML\n\tprivate VBox detailsView;\n\n\t@FXML\n\tprivate GridPane profilePane;\n\n\t@FXML\n\tprivate Label badgeOwn;\n\n\t@FXML\n\tprivate Label badgePartial;\n\n\t@FXML\n\tprivate Label badgeAccepted;\n\n\t@FXML\n\tprivate Label badgeUnvalidated;\n\n\t@FXML\n\tprivate VBox locationsView;\n\n\t@FXML\n\tprivate Button chatButton;\n\n\t@FXML\n\tprivate CheckMenuItem showAllContacts;\n\n\t@FXML\n\tprivate TableView<Location> locationTableView;\n\n\t@FXML\n\tprivate TableColumn<Location, String> locationTableNameColumn;\n\n\t@FXML\n\tprivate TableColumn<Location, Availability> locationTablePresenceColumn;\n\n\t@FXML\n\tprivate TableColumn<Location, String> locationTableIPColumn;\n\n\t@FXML\n\tprivate TableColumn<Location, String> locationTablePortColumn;\n\n\t@FXML\n\tprivate TableColumn<Location, String> locationTableLastConnectedColumn;\n\n\tprivate final ContactClient contactClient;\n\tprivate final GeneralClient generalClient;\n\tprivate final ProfileClient profileClient;\n\tprivate final IdentityClient identityClient;\n\tprivate final NotificationClient notificationClient;\n\tprivate final ImageCache imageCacheService;\n\tprivate final WindowManager windowManager;\n\tprivate final ResourceBundle bundle;\n\n\tprivate Disposable contactNotificationDisposable;\n\tprivate Disposable availabilityNotificationDisposable;\n\n\tprivate final ObservableList<TreeItem<Contact>> contactObservableList = FXCollections.observableArrayList(p -> new Observable[]{p.valueProperty()}); // Changing the value will mark the list as changed so that sorting works, etc...\n\tprivate final SortedList<TreeItem<Contact>> sortedList = new SortedList<>(contactObservableList);\n\tprivate final FilteredList<TreeItem<Contact>> filteredList = new FilteredList<>(sortedList);\n\tprivate final ContactFilter contactFilter = new ContactFilter(filteredList);\n\tprivate FontIcon searchClear;\n\n\tprivate final TreeItem<Contact> treeRoot = new TreeItem<>(Contact.EMPTY);\n\n\tprivate TreeItem<Contact> ownContact;\n\n\t// Workaround for https://bugs.openjdk.org/browse/JDK-8090563\n\tprivate TreeItem<Contact> selectedItem;\n\tprivate TreeItem<Contact> displayedContact;\n\tprivate boolean contactListLocked;\n\n\tpublic ContactViewController(ContactClient contactClient, GeneralClient generalClient, ProfileClient profileClient, IdentityClient identityClient, NotificationClient notificationClient, ImageCache imageCacheService, ResourceBundle bundle, WindowManager windowManager, ConfigClient configClient, ConnectionClient connectionClient)\n\t{\n\t\tthis.contactClient = contactClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.profileClient = profileClient;\n\t\tthis.identityClient = identityClient;\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.imageCacheService = imageCacheService;\n\t\tthis.bundle = bundle;\n\t\tthis.windowManager = windowManager;\n\t\tthis.configClient = configClient;\n\t\tthis.connectionClient = connectionClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tsearchClear = new FontIcon(MaterialDesignC.CLOSE_CIRCLE);\n\n\t\tcontactImageSelectorView.setImageLoader(url -> generalClient.getImage(url).block());\n\t\tcontactImageSelectorView.setImageCache(imageCacheService);\n\n\t\tsetupContactSearch();\n\t\tsetupContactTreeTableView();\n\t\tsetupLocationTableView();\n\n\t\tsetupMenuFilters();\n\n\t\tcontactImageSelectorView.setOnSelectAction(this::selectOwnContactImage);\n\t\tcontactImageSelectorView.setOnDeleteAction(_ -> UiUtils.showAlertConfirm(bundle.getString(\"contact-view.avatar-delete.confirm\"), () -> identityClient.deleteIdentityImage(OWN_IDENTITY_ID).subscribe()));\n\n\t\tchatButton.setOnAction(_ -> startChat(displayedContact.getValue()));\n\n\t\tsetupOwnContact();\n\n\t\ttrust.getItems().addAll(Arrays.stream(Trust.values()).filter(t -> t != Trust.ULTIMATE).toList());\n\n\t\tsetupContactNotifications();\n\t\tsetupConnectionNotifications();\n\n\t\tgetContacts();\n\t}\n\n\tprivate void setupOwnContact()\n\t{\n\t\townContactImageView.setLoader(url -> generalClient.getImage(url).block());\n\t\townContactImageView.setOnSuccess(() -> {\n\t\t\townContactCircle.setVisible(true);\n\t\t\townContactCircle.setFill(new ImagePattern(ownContactImageView.getImage()));\n\t\t});\n\t\tif (Platform.isSupported(ConditionalFeature.EFFECT))\n\t\t{\n\t\t\townContactCircle.setEffect(new DropShadow(6, Color.rgb(0, 0, 0, 0.7)));\n\t\t\townContactState.setEffect(new DropShadow(4, Color.rgb(0, 0, 0, 0.9)));\n\t\t}\n\t\townContactImageView.setImageCache(imageCacheService);\n\n\t\tprofileClient.getOwn()\n\t\t\t\t.doOnSuccess(profile -> {\n\t\t\t\t\tassert profile != null;\n\t\t\t\t\townContactName.setText(profile.getName());\n\t\t\t\t\townContact = new TreeItem<>(Contact.withName(Contact.OWN, profile.getName()));\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t\tdisplayOwnContactImage();\n\n\t\tUiUtils.setOnPrimaryMouseClicked(ownContactGroup, _ -> displayOwnContact());\n\n\t\tcreateStateContextMenu();\n\t}\n\n\tprivate void displayOwnContact()\n\t{\n\t\tif (ownContact == null)\n\t\t{\n\t\t\tlog.error(\"Failure to load own contact, can't display\");\n\t\t\treturn;\n\t\t}\n\t\tcontactTreeTableView.getSelectionModel().clearSelection();\n\t\tdisplayContact(ownContact);\n\t}\n\n\tprivate void displayOwnContactImage()\n\t{\n\t\townContactImageView.setUrl(ContactUtils.getIdentityImageUrl(Contact.OWN));\n\t}\n\n\tprivate void setupContactSearch()\n\t{\n\t\tsearchClear.setCursor(Cursor.HAND);\n\t\tUiUtils.setOnPrimaryMouseClicked(searchClear, _ -> searchTextField.clear());\n\n\t\tTextInputControlUtils.addEnhancedInputContextMenu(searchTextField, null, null);\n\t\tsearchTextField.textProperty().addListener((_, _, newValue) -> contactFilter.setNameFilter(newValue));\n\t\tsearchTextField.lengthProperty().addListener((_, _, newValue) -> {\n\t\t\tif (newValue.intValue() > 0)\n\t\t\t{\n\t\t\t\tsearchTextField.setRight(searchClear);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsearchTextField.setRight(null);\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void setupContactTreeTableView()\n\t{\n\t\tcontactTreeTableNameColumn.setCellFactory(_ -> new ContactCellName(generalClient, imageCacheService));\n\t\tcontactTreeTableNameColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue()));\n\n\t\tcontactTreeTablePresenceColumn.setCellFactory(_ -> new AvailabilityTreeCellStatus<>());\n\t\tcontactTreeTablePresenceColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue().availability()));\n\n\t\t// Sort by connected first then name\n\t\tcontactTreeTableView.setSortPolicy(_ -> true); // This is needed otherwise the default sorting will break everything\n\t\tcontactTreeTablePresenceColumn.setSortType(TreeTableColumn.SortType.ASCENDING);\n\t\tcontactTreeTablePresenceColumn.setSortable(true);\n\t\tcontactTreeTableNameColumn.setSortType(TreeTableColumn.SortType.ASCENDING);\n\t\tcontactTreeTableNameColumn.setSortable(true);\n\n\t\t// Do not allow selection of multiple entries\n\t\tcontactTreeTableView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);\n\n\t\tcontactTreeTableView.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> {\n\t\t\tif (contactListLocked)\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t//log.debug(\"Selection property changed, old: {}, new: {}\", oldValue, newValue);\n\t\t\tdisplayContact(newValue);\n\t\t});\n\n\t\ttreeRoot.setExpanded(true);\n\n\t\tBindings.bindContent(treeRoot.getChildren(), filteredList);\n\n\t\tsortedList.comparatorProperty().bind(contactTreeTableView.comparatorProperty());\n\n\t\t// Because of JDK-8248217 (and others), we have\n\t\t// to save the selection before the sortedList (and then filteredList) are\n\t\t// updated.\n\t\tsortedList.addListener((InvalidationListener) _ -> {\n\t\t\t//log.debug(\"Sorting invalidated, selected index: {}\", contactTreeTableView.getSelectionModel().getSelectedIndex());\n\t\t\tif (!sortedList.isEmpty()) // Empty lists don't get passed on to filtered list and would get locked forever\n\t\t\t{\n\t\t\t\tcontactListLocked = true;\n\t\t\t}\n\t\t\tselectedItem = contactTreeTableView.getSelectionModel().getSelectedItem();\n\t\t});\n\n\t\t// Then we restore the selection after filteredList has been\n\t\t// updated.\n\t\tfilteredList.addListener((ListChangeListener<? super TreeItem<Contact>>) _ -> {\n\t\t\t//log.debug(\"FilteredList changed, actions: {}\", c);\n\n\t\t\t// We must call this otherwise the selection is lost\n\t\t\tcontactTreeTableView.getSelectionModel().select(selectedItem);\n\n\t\t\tcontactListLocked = false;\n\t\t});\n\n\t\tcontactTreeTableView.setRoot(treeRoot);\n\t\tcontactTreeTableView.setShowRoot(false);\n\n\t\tcreateContactTableViewContextMenu();\n\t}\n\n\tprivate void scrollToSelectedContact()\n\t{\n\t\tvar index = contactTreeTableView.getSelectionModel().getSelectedIndex();\n\t\tif (index != -1)\n\t\t{\n\t\t\tcontactTreeTableView.scrollTo(index);\n\t\t}\n\t}\n\n\tprivate void setupLocationTableView()\n\t{\n\t\tlocationTableView.setRowFactory(_ -> new LocationRow());\n\n\t\tlocationTableNameColumn.setCellValueFactory(new PropertyValueFactory<>(\"name\"));\n\t\tlocationTablePresenceColumn.setCellFactory(_ -> new AvailabilityCellStatus<>());\n\t\tlocationTablePresenceColumn.setCellValueFactory(param -> new SimpleObjectProperty<>(getLocationAvailability(param.getValue())));\n\t\tlocationTableIPColumn.setCellValueFactory(param -> {\n\t\t\tvar hostPort = getConnectedAddress(param.getValue());\n\t\t\treturn new SimpleStringProperty(hostPort != null ? hostPort.host() : \"-\");\n\t\t});\n\t\tlocationTablePortColumn.setCellValueFactory(param -> {\n\t\t\tvar hostPort = getConnectedAddress(param.getValue());\n\t\t\treturn new SimpleStringProperty(hostPort != null ? String.valueOf(hostPort.port()) : \"-\");\n\t\t});\n\t\tlocationTableLastConnectedColumn.setCellValueFactory(param -> new SimpleStringProperty(getLastConnection(param.getValue())));\n\n\t\tcreateLocationTableContextMenu();\n\t}\n\n\t/**\n\t * Gets the true availability state of a location. Location has no concept of offline presence.\n\t *\n\t * @param location the location\n\t * @return the location's availability state\n\t */\n\tprivate static Availability getLocationAvailability(Location location)\n\t{\n\t\treturn location.isConnected() ? location.getAvailability() : Availability.OFFLINE;\n\t}\n\n\tprivate void setupMenuFilters()\n\t{\n\t\tvar prefsNode = PreferenceUtils.getPreferences().node(CONTACTS);\n\t\tshowAllContacts.selectedProperty().addListener((_, _, newValue) -> {\n\t\t\tcontactFilter.setShowAllContacts(newValue);\n\t\t\tprefsNode.putBoolean(SHOW_ALL_CONTACTS, newValue);\n\t\t});\n\t\tshowAllContacts.selectedProperty().set(prefsNode.getBoolean(SHOW_ALL_CONTACTS, false));\n\t}\n\n\tprivate void getContacts()\n\t{\n\t\tMap<Long, TreeItem<Contact>> contacts = new HashMap<>();\n\t\tList<TreeItem<Contact>> identities = new ArrayList<>();\n\n\t\tcontactClient.getContacts()\n\t\t\t\t.doOnNext(contact -> {\n\t\t\t\t\tif (contact.profileId() != NO_PROFILE_ID)\n\t\t\t\t\t{\n\t\t\t\t\t\tif (contact.identityId() != NO_IDENTITY_ID)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif (contact.identityId() == OWN_IDENTITY_ID || contact.profileId() == OWN_PROFILE_ID)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Own profile, we don't add it to the list\n\t\t\t\t\t\t\t\t// because it has its own section above.\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (contacts.containsKey(contact.profileId()))\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvar profile = contacts.get(contact.profileId());\n\t\t\t\t\t\t\t\tupdateProfileWithIdentity(profile, new TreeItem<>(contact));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tcontacts.put(contact.profileId(), new TreeItem<>(contact));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif (contact.profileId() == OWN_IDENTITY_ID)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Own profile, we don't add it to the list\n\t\t\t\t\t\t\t\t// because it has its own section above.\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (contacts.put(contact.profileId(), new TreeItem<>(contact)) != null)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tthrow new IllegalStateException(\"Profile overwritten\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tidentities.add(new TreeItem<>(contact));\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.doOnComplete(() -> Platform.runLater(() -> {\n\t\t\t\t\t// Add all contacts\n\t\t\t\t\tcontactObservableList.addAll(contacts.values());\n\t\t\t\t\tcontactObservableList.addAll(identities);\n\n\t\t\t\t\t//noinspection unchecked\n\t\t\t\t\tcontactTreeTableView.getSortOrder().setAll(contactTreeTablePresenceColumn, contactTreeTableNameColumn);\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void updateProfileWithIdentity(TreeItem<Contact> profile, TreeItem<Contact> identity)\n\t{\n\t\tif (profile.getValue().identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\t// Profile with an identity already\n\t\t\tif (profile.getValue().identityId() == identity.getValue().identityId())\n\t\t\t{\n\t\t\t\t// Same identity, we replace it\n\t\t\t\tprofile.setValue(identity.getValue());\n\t\t\t\trefreshContactIfNeeded(profile);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (profile.getChildren().isEmpty())\n\t\t\t\t{\n\t\t\t\t\t// Not the same, we replace if we have a matching name\n\t\t\t\t\tif (!replaceIfSameName(profile, identity))\n\t\t\t\t\t{\n\t\t\t\t\t\tprofile.getChildren().add(identity);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tif (!replaceIfSameName(profile, identity))\n\t\t\t\t\t{\n\t\t\t\t\t\treplaceOrAddChildren(profile, identity);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Lone profile that gets an identity added\n\t\t\tif (!replaceIfSameName(profile, identity))\n\t\t\t{\n\t\t\t\tprofile.getChildren().add(identity);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate boolean replaceIfSameName(TreeItem<Contact> profile, TreeItem<Contact> identity)\n\t{\n\t\tif (profile.getValue().name().equalsIgnoreCase(identity.getValue().name()))\n\t\t{\n\t\t\tprofile.setValue(identity.getValue());\n\t\t\trefreshContactIfNeeded(profile);\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate void replaceOrAddChildren(TreeItem<Contact> parent, TreeItem<Contact> identity)\n\t{\n\t\tfor (TreeItem<Contact> child : parent.getChildren())\n\t\t{\n\t\t\tif (child.getValue().identityId() == identity.getValue().identityId())\n\t\t\t{\n\t\t\t\tchild.setValue(identity.getValue());\n\t\t\t\trefreshContactIfNeeded(child);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tparent.getChildren().add(identity);\n\t}\n\n\tprivate void setupContactNotifications()\n\t{\n\t\tcontactNotificationDisposable = notificationClient.getContactNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tObjects.requireNonNull(sse.data());\n\n\t\t\t\t\tswitch (sse.data())\n\t\t\t\t\t{\n\t\t\t\t\t\tcase AddOrUpdateContacts action -> action.contacts().forEach(this::addContact);\n\t\t\t\t\t\tcase RemoveContacts action -> action.contacts().forEach(this::removeContact);\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void setupConnectionNotifications()\n\t{\n\t\tavailabilityNotificationDisposable = notificationClient.getAvailabilityNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tObjects.requireNonNull(sse.data());\n\t\t\t\t\tupdateContactConnection(sse.data().profileId(), sse.data().locationId(), sse.data().availability());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate TreeItem<Contact> findProfile(long profileId)\n\t{\n\t\treturn contactObservableList.stream()\n\t\t\t\t.filter(existingContact -> existingContact.getValue().profileId() == profileId)\n\t\t\t\t.findFirst().orElse(null);\n\t}\n\n\tprivate void clearCachedImages(TreeItem<Contact> contact)\n\t{\n\t\timageCacheService.evictImage(ContactUtils.getIdentityImageUrl(contact.getValue()));\n\n\t\t// Make sure AsyncImageView doesn't refuse to load the\n\t\t// url because it thinks it's already loaded.\n\t\tif (displayedContact != null && displayedContact.getValue().equals(contact.getValue()))\n\t\t{\n\t\t\tcontactImageSelectorView.setImage(null);\n\t\t}\n\t}\n\n\tprivate void addContact(Contact contact)\n\t{\n\t\t//log.debug(\"Adding contact {}\", contact);\n\n\t\tif (contact.profileId() != NO_PROFILE_ID && contact.identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\tif (contact.identityId() == OWN_IDENTITY_ID)\n\t\t\t{\n\t\t\t\t// Own identity, special handling\n\t\t\t\tObjects.requireNonNull(ownContact);\n\t\t\t\tclearCachedImages(ownContact);\n\t\t\t\townContactImageView.setUrl(null);\n\t\t\t\townContactCircle.setVisible(false);\n\t\t\t\tdisplayOwnContact();\n\t\t\t\tdisplayOwnContactImage();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Full contact\n\t\t\tvar existing = findProfile(contact.profileId());\n\t\t\tvar item = new TreeItem<>(contact);\n\n\t\t\tif (existing != null)\n\t\t\t{\n\t\t\t\tclearCachedImages(existing);\n\t\t\t\tupdateProfileWithIdentity(existing, item);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontactObservableList.add(item);\n\t\t\t}\n\t\t}\n\t\telse if (contact.profileId() != NO_PROFILE_ID)\n\t\t{\n\t\t\tif (contact.profileId() == OWN_PROFILE_ID)\n\t\t\t{\n\t\t\t\t// Own profile, special handling\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Lone profile\n\t\t\tvar existing = findProfile(contact.profileId());\n\t\t\tvar item = new TreeItem<>(contact);\n\n\t\t\tif (existing != null)\n\t\t\t{\n\t\t\t\t// This is a profile update (eg. different trust). We need to restore\n\t\t\t\t// the identity otherwise it won't display its image\n\t\t\t\tif (existing.getValue().identityId() != NO_IDENTITY_ID)\n\t\t\t\t{\n\t\t\t\t\texisting.setValue(Contact.withIdentityId(contact, existing.getValue().identityId()));\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\texisting.setValue(contact);\n\t\t\t\t}\n\t\t\t\trefreshContactIfNeeded(existing);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontactObservableList.add(item);\n\t\t\t}\n\t\t}\n\t\telse if (contact.identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\t// Lone identity\n\t\t\tvar existing = contactObservableList.stream()\n\t\t\t\t\t.filter(existingContact -> existingContact.getValue().identityId() == contact.identityId())\n\t\t\t\t\t.findFirst().orElse(null);\n\n\t\t\tif (existing != null)\n\t\t\t{\n\t\t\t\tclearCachedImages(existing);\n\t\t\t\texisting.setValue(contact);\n\t\t\t\trefreshContactIfNeeded(existing);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontactObservableList.add(new TreeItem<>(contact));\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Empty contact (identity == 0L and profile == 0L). Shouldn't happen.\");\n\t\t}\n\t}\n\n\tprivate void removeContact(Contact contact)\n\t{\n\t\t//log.debug(\"Removing contact {}\", contact);\n\t\tif (contact.identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\tcontactObservableList.removeIf(existingContact -> existingContact.getValue().identityId() == contact.identityId());\n\t\t}\n\t\telse if (contact.profileId() != NO_PROFILE_ID)\n\t\t{\n\t\t\tcontactObservableList.removeIf(existingContact -> existingContact.getValue().profileId() == contact.profileId());\n\t\t}\n\t\t// XXX: unselect if it was selected?\n\t}\n\n\tprivate void updateContactConnection(long profileId, long locationId, Availability availability)\n\t{\n\t\tlog.debug(\"Updating contact connection {} with availability {}\", profileId, availability);\n\n\t\tif (locationId == OWN_LOCATION_ID)\n\t\t{\n\t\t\tsetOwnContactState(availability);\n\t\t\treturn;\n\t\t}\n\n\t\tvar existing = contactObservableList.stream()\n\t\t\t\t.filter(existingContact -> existingContact.getValue().profileId() == profileId)\n\t\t\t\t.findFirst().orElse(null);\n\n\t\tif (existing == null)\n\t\t{\n\t\t\tlog.debug(\"Contact for profile {} not found. Removed then disconnected?\", profileId);\n\t\t\treturn;\n\t\t}\n\n\t\t// XXX: we do need to comment out the next one otherwise a new location is not detected! how to filter the double refresh problem though?\n\t\t//if (existing.getValue().availability() != availability) // Avoid useless refreshes\n\t\t{\n\t\t\tif (existing.isLeaf())\n\t\t\t{\n\t\t\t\texisting.setValue(Contact.withAvailability(existing.getValue(), availability));\n\t\t\t\trefreshContactIfNeeded(existing);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// There are children, we need to use a different algorithm then.\n\t\t\t\tprofileClient.findById(profileId)\n\t\t\t\t\t\t.doOnSuccess(profile -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert profile != null;\n\t\t\t\t\t\t\texisting.setValue(Contact.withAvailability(existing.getValue(), profile.getLocations().stream()\n\t\t\t\t\t\t\t\t\t.filter(Location::isConnected)\n\t\t\t\t\t\t\t\t\t.min(Comparator.comparing(location -> location.getAvailability().ordinal()))\n\t\t\t\t\t\t\t\t\t.map(Location::getAvailability)\n\t\t\t\t\t\t\t\t\t.orElse(Availability.OFFLINE)));\n\t\t\t\t\t\t\trefreshContactIfNeeded(existing);\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void setOwnContactState(Availability availability)\n\t{\n\t\tswitch (availability)\n\t\t{\n\t\t\tcase AVAILABLE -> ownContactState.setFill(Color.LIMEGREEN);\n\t\t\tcase AWAY -> ownContactState.setFill(Color.ORANGE);\n\t\t\tcase BUSY -> ownContactState.setFill(Color.RED);\n\t\t\tcase OFFLINE -> ownContactState.setFill(Color.GRAY);\n\t\t}\n\t}\n\n\tprivate HostPort getConnectedAddress(Location location)\n\t{\n\t\tif (location.isConnected())\n\t\t{\n\t\t\tvar connection = location.getConnections().stream().max(Comparator.comparing(Connection::getLastConnected, Comparator.nullsFirst(Comparator.naturalOrder()))).orElse(null);\n\t\t\tif (connection != null)\n\t\t\t{\n\t\t\t\treturn HostPort.parse(connection.getAddress());\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate String getLastConnection(Location location)\n\t{\n\t\tif (location.isConnected())\n\t\t{\n\t\t\treturn bundle.getString(\"contact-view.location.last-connected.now\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar lastConnected = location.getLastConnected();\n\t\t\tif (lastConnected == null)\n\t\t\t{\n\t\t\t\treturn bundle.getString(\"contact-view.location.last-connected.never\");\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn DATE_TIME_FORMAT.format(lastConnected);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void setTrust(Profile profile)\n\t{\n\t\tclearTrust();\n\t\ttrust.getSelectionModel().select(profile.getTrust());\n\t\tif (profile.isOwn())\n\t\t{\n\t\t\ttrustLabel.setVisible(false);\n\t\t\ttrust.setVisible(false);\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttrustLabel.setVisible(true);\n\t\t\ttrust.setVisible(true);\n\t\t}\n\t\ttrust.setDisable(profile.isOwn());\n\t\ttrust.setOnAction(_ -> profileClient.setTrust(profile.getId(), trust.getSelectionModel().getSelectedItem()).subscribe());\n\t}\n\n\tprivate void clearTrust()\n\t{\n\t\ttrust.setOnAction(null);\n\t}\n\n\t/**\n\t * Displays the contact. To be called by the list selector or the own contact display.\n\t *\n\t * @param contact the contact to display\n\t */\n\tprivate void displayContact(TreeItem<Contact> contact)\n\t{\n\t\tdisplayContact(contact, false);\n\t}\n\n\t/**\n\t * Refreshes the contact if needed. To be called after each modification of any contact because the listview\n\t * won't do it by itself.\n\t *\n\t * @param contact the contact\n\t */\n\tprivate void refreshContactIfNeeded(TreeItem<Contact> contact)\n\t{\n\t\tif (displayedContact == contact)\n\t\t{\n\t\t\tdisplayContact(contact, true);\n\t\t}\n\t}\n\n\t/**\n\t * Displays the contact. Do not call this method directly! Use {@link #displayContact(TreeItem)} or\n\t * {@link #refreshContactIfNeeded(TreeItem)} instead.\n\t *\n\t * @param contact the contact\n\t * @param force   to force the refresh\n\t */\n\tprivate void displayContact(TreeItem<Contact> contact, boolean force)\n\t{\n\t\tif (contactListLocked && !force)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (contact == null)\n\t\t{\n\t\t\tdisplayedContact = null;\n\t\t\tclearSelection();\n\t\t\treturn;\n\t\t}\n\n\t\tdisplayedContact = contact;\n\n\t\thideBadges();\n\t\tclearTrust();\n\t\tTooltipUtils.uninstall(idLabel);\n\t\tTooltipUtils.uninstall(typeLabel);\n\t\tTooltipUtils.uninstall(createdLabel);\n\t\tcontactImageSelectorView.setEditable(false);\n\t\tdetailsHeader.setVisible(true);\n\t\tdetailsView.setVisible(true);\n\t\tnameLabel.setText(contact.getValue().name());\n\t\tsetChatButtonVisual(contact.getValue());\n\t\tcontactImageSelectorView.setImageUrl(ContactUtils.getIdentityImageUrl(contact.getValue()));\n\t\tif (contact.getValue().profileId() != NO_PROFILE_ID && contact.getValue().identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\ttypeLabel.setText(bundle.getString(\"contact-view.information.linked-to-profile\"));\n\n\t\t\tfetchProfile(contact.getValue().profileId(), Information.MERGED, isSubContact(contact));\n\t\t\tfetchIdentity(contact.getValue().identityId(), Information.MERGED);\n\t\t}\n\t\telse if (contact.getValue().profileId() != NO_PROFILE_ID)\n\t\t{\n\t\t\ttypeLabel.setText(bundle.getString(\"contact-view.information.profile\"));\n\n\t\t\tfetchProfile(contact.getValue().profileId(), Information.PROFILE, false);\n\t\t}\n\t\telse if (contact.getValue().identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\tprofilePane.setVisible(false);\n\n\t\t\ttypeLabel.setText(bundle.getString(\"contact-view.information.identity\"));\n\t\t\thideTableLocations();\n\n\t\t\tfetchIdentity(contact.getValue().identityId(), Information.IDENTITY);\n\t\t}\n\t}\n\n\tprivate void setChatButtonVisual(Contact contact)\n\t{\n\t\tif (contact.profileId() == OWN_PROFILE_ID || contact.identityId() == OWN_IDENTITY_ID)\n\t\t{\n\t\t\tchatButton.setGraphic(new FontIcon(MaterialDesignM.MESSAGE));\n\t\t\tchatButton.setDisable(true);\n\t\t\tTooltipUtils.uninstall(chatButton);\n\t\t}\n\t\telse if (contact.profileId() != NO_PROFILE_ID)\n\t\t{\n\t\t\tchatButton.setGraphic(new FontIcon(MaterialDesignM.MESSAGE));\n\t\t\tchatButton.setDisable(contact.availability() == Availability.OFFLINE);\n\t\t\tTooltipUtils.install(chatButton, bundle.getString(\"contact-view.chat.start\"));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tchatButton.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT));\n\t\t\tchatButton.setDisable(false);\n\t\t\tTooltipUtils.install(chatButton, bundle.getString(\"contact-view.distant-chat.start\"));\n\t\t}\n\t}\n\n\tprivate void clearSelection()\n\t{\n\t\tcontactImageSelectorView.setEditable(false);\n\t\tdetailsHeader.setVisible(false);\n\t\tdetailsView.setVisible(false);\n\t\tnameLabel.setText(null);\n\t\tidLabel.setText(null);\n\t\tTooltipUtils.uninstall(idLabel);\n\t\tTooltipUtils.uninstall(createdLabel);\n\t\ttypeLabel.setText(null);\n\t\tTooltipUtils.uninstall(typeLabel);\n\t\tcreatedLabel.setText(null);\n\t\tcontactImageSelectorView.setImage(null);\n\t\tprofilePane.setVisible(false);\n\t\thideTableLocations();\n\t}\n\n\tprivate void fetchProfile(long profileId, Information information, boolean isLeaf)\n\t{\n\t\tprofileClient.findById(profileId)\n\t\t\t\t.doOnSuccess(profile -> Platform.runLater(() -> {\n\t\t\t\t\tassert profile != null;\n\t\t\t\t\tshowProfileInformation(profile, information);\n\t\t\t\t\tshowBadges(profile);\n\t\t\t\t\tsetTrust(profile);\n\t\t\t\t\ttrust.setDisable(isLeaf || !profile.isAccepted());\n\t\t\t\t\tprofilePane.setVisible(true);\n\t\t\t\t\tshowTableLocations(profile.getLocations());\n\t\t\t\t}))\n\t\t\t\t.doOnError(throwable -> {\n\t\t\t\t\tif (throwable instanceof WebClientResponseException wEx && wEx.getStatusCode() == HttpStatus.NOT_FOUND)\n\t\t\t\t\t{\n\t\t\t\t\t\tPlatform.runLater(() -> UiUtils.setPresent(badgeUnvalidated, true));\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void showProfileInformation(Profile profile, Information information)\n\t{\n\t\tif (information == Information.PROFILE)\n\t\t{\n\t\t\tidLabel.setText(Id.toString(profile.getPgpIdentifier()));\n\t\t\tshowProfileKeyInformation(profile, idLabel);\n\t\t}\n\t\tif (information == Information.PROFILE || information == Information.MERGED)\n\t\t{\n\t\t\tcreatedOrUpdated.setText(bundle.getString(\"contact-view.information.created\"));\n\t\t\tcreatedLabel.setText(profile.getCreated() != null ? DATE_TIME_FORMAT.format(profile.getCreated()) : bundle.getString(\"contact-view.information.created-unknown\"));\n\t\t\tif (information == Information.MERGED)\n\t\t\t{\n\t\t\t\ttypeLabel.setText(bundle.getString(\"contact-view.information.linked-to-profile\") + \" \" + Id.toString(profile.getPgpIdentifier()));\n\t\t\t\tshowProfileKeyInformation(profile, typeLabel);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void showProfileKeyInformation(Profile profile, Label node)\n\t{\n\t\tif (!profile.isPartial())\n\t\t{\n\t\t\tTooltipUtils.install(node, delayedTooltip -> profileClient.findProfileKeyAttributes(profile.getId())\n\t\t\t\t\t.doOnSuccess(profileKeyAttributes -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert profileKeyAttributes != null;\n\t\t\t\t\t\tdelayedTooltip.show(getKeyInformation(profileKeyAttributes));\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe());\n\t\t}\n\t}\n\n\tprivate String getKeyInformation(ProfileKeyAttributes profileKeyAttributes)\n\t{\n\t\t// EC keys don't return the length for some reason\n\t\tif (profileKeyAttributes.keyBits() > 0)\n\t\t{\n\t\t\treturn MessageFormat.format(bundle.getString(\"contact-view.information.key-information-with-length\"),\n\t\t\t\t\tprofileKeyAttributes.version(),\n\t\t\t\t\tPublicKeyUtils.getKeyAlgorithmName(profileKeyAttributes.keyAlgorithm()),\n\t\t\t\t\tprofileKeyAttributes.keyBits(),\n\t\t\t\t\tPublicKeyUtils.getSignatureHash(profileKeyAttributes.signatureHash()));\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn MessageFormat.format(bundle.getString(\"contact-view.information.key-information\"),\n\t\t\t\t\tprofileKeyAttributes.version(),\n\t\t\t\t\tPublicKeyUtils.getKeyAlgorithmName(profileKeyAttributes.keyAlgorithm()),\n\t\t\t\t\tPublicKeyUtils.getSignatureHash(profileKeyAttributes.signatureHash()));\n\t\t}\n\t}\n\n\tprivate void showBadges(Profile profile)\n\t{\n\t\tUiUtils.setPresent(badgeOwn, profile.isOwn());\n\t\tUiUtils.setPresent(badgePartial, profile.isPartial());\n\t\tUiUtils.setPresent(badgeAccepted, profile.isAccepted());\n\t\tUiUtils.setAbsent(badgeUnvalidated);\n\t}\n\n\tprivate void hideBadges()\n\t{\n\t\tUiUtils.setAbsent(badgeOwn);\n\t\tUiUtils.setAbsent(badgePartial);\n\t\tUiUtils.setAbsent(badgeAccepted);\n\t\tUiUtils.setAbsent(badgeUnvalidated);\n\t}\n\n\tprivate void fetchIdentity(long identityId, Information information)\n\t{\n\t\tidentityClient.findById(identityId)\n\t\t\t\t.doOnSuccess(identity -> Platform.runLater(() -> {\n\t\t\t\t\tassert identity != null;\n\t\t\t\t\tif (information == Information.IDENTITY || information == Information.MERGED)\n\t\t\t\t\t{\n\t\t\t\t\t\tidLabel.setText(Id.toString(identity.getGxsId()));\n\t\t\t\t\t\tTooltipUtils.install(createdLabel, \"Last updated: \" + DateUtils.formatDateTime(identity.getUpdated(), \"unknown\"));\n\t\t\t\t\t}\n\t\t\t\t\tif (information == Information.IDENTITY)\n\t\t\t\t\t{\n\t\t\t\t\t\tcreatedOrUpdated.setText(bundle.getString(\"contact-view.information.updated\"));\n\t\t\t\t\t\tcreatedLabel.setText(DATE_TIME_FORMAT.format(identity.getUpdated()));\n\t\t\t\t\t}\n\t\t\t\t\tif (identityId == OWN_IDENTITY_ID)\n\t\t\t\t\t{\n\t\t\t\t\t\tcontactImageSelectorView.setEditable(true, identity.hasImage());\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.doOnError(_ -> Platform.runLater(this::clearSelection))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void showTableLocations(List<Location> locations)\n\t{\n\t\tif (locations.isEmpty())\n\t\t{\n\t\t\thideTableLocations();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tlocationTableView.getItems().setAll(locations);\n\t\t\tlocationsView.setVisible(true);\n\t\t}\n\t}\n\n\tprivate void hideTableLocations()\n\t{\n\t\tlocationsView.setVisible(false);\n\t}\n\n\tprivate void createContactTableViewContextMenu()\n\t{\n\t\t// The chat menu item can morph between chat and distant chat\n\t\tvar chatItem = new MenuItem(bundle.getString(\"contact-view.action.chat\"));\n\t\tchatItem.setId(CHAT_MENU_ID);\n\t\tchatItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var contact = ((TreeItem<Contact>) event.getSource()).getValue();\n\t\t\tstartChat(contact);\n\t\t});\n\n\t\t// And the distant chat menu item can disappear all together\n\t\tvar distantChatItem = new MenuItem(bundle.getString(\"contact-view.action.distant-chat\"));\n\t\tdistantChatItem.setId(DISTANT_CHAT_MENU_ID);\n\t\tdistantChatItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT));\n\t\tdistantChatItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var contact = ((TreeItem<Contact>) event.getSource()).getValue();\n\t\t\tstartDistantChat(contact);\n\t\t});\n\n\t\tvar deleteItem = new MenuItem(bundle.getString(\"profiles.delete\"));\n\t\tdeleteItem.setId(DELETE_MENU_ID);\n\t\tdeleteItem.setGraphic(new FontIcon(MaterialDesignA.ACCOUNT_REMOVE));\n\t\tdeleteItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var contact = (TreeItem<Contact>) event.getSource();\n\t\t\tif (contact.getValue().profileId() != NO_PROFILE_ID && contact.getValue().profileId() != OWN_PROFILE_ID)\n\t\t\t{\n\t\t\t\tUiUtils.showAlertConfirm(MessageFormat.format(bundle.getString(\"contact-view.profile-delete.confirm\"), contact.getValue().name()), () -> profileClient.delete(contact.getValue().profileId())\n\t\t\t\t\t\t.subscribe());\n\t\t\t}\n\t\t});\n\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy-link\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tcopyLinkItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var contact = (TreeItem<Contact>) event.getSource();\n\t\t\tif (contact.getValue().profileId() != NO_PROFILE_ID)\n\t\t\t{\n\t\t\t\tprofileClient.findById(contact.getValue().profileId())\n\t\t\t\t\t\t.doOnSuccess(profile -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert profile != null;\n\t\t\t\t\t\t\tClipboardUtils.copyTextToClipboard(new ProfileUri(profile.getName(), profile.getPgpIdentifier()).toUriString());\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t\telse if (contact.getValue().identityId() != NO_IDENTITY_ID)\n\t\t\t{\n\t\t\t\tidentityClient.findById(contact.getValue().identityId())\n\t\t\t\t\t\t.doOnSuccess(identity -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert identity != null;\n\t\t\t\t\t\t\tClipboardUtils.copyTextToClipboard(new IdentityUri(identity.getName(), identity.getGxsId(), null).toUriString());\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<TreeItem<Contact>>(chatItem, distantChatItem, copyLinkItem, new SeparatorMenuItem(), deleteItem);\n\t\txContextMenu.setOnShowing((contextMenu, contact) -> {\n\t\t\tif (contact == null)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> CHAT_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> {\n\t\t\t\t\t\tif (contact.getValue().profileId() == OWN_PROFILE_ID || contact.getValue().identityId() == OWN_IDENTITY_ID)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmenuItem.setText(bundle.getString(\"contact-view.action.chat\"));\n\t\t\t\t\t\t\tmenuItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE));\n\t\t\t\t\t\t\tmenuItem.setDisable(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse if (contact.getValue().profileId() != NO_PROFILE_ID)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmenuItem.setText(bundle.getString(\"contact-view.action.chat\"));\n\t\t\t\t\t\t\tmenuItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE));\n\t\t\t\t\t\t\tmenuItem.setDisable(contact.getValue().availability() == Availability.OFFLINE);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmenuItem.setText(bundle.getString(\"contact-view.action.distant-chat\"));\n\t\t\t\t\t\t\tmenuItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE_ARROW_RIGHT));\n\t\t\t\t\t\t\tmenuItem.setDisable(false);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> DISTANT_CHAT_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> {\n\t\t\t\t\t\tif (contact.getValue().profileId() != NO_PROFILE_ID)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmenuItem.setVisible(contact.getValue().availability() == Availability.OFFLINE);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmenuItem.setVisible(false);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> COPY_LINK_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(contact.getValue().profileId() == NO_PROFILE_ID && contact.getValue().identityId() == NO_IDENTITY_ID));\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> DELETE_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(isSubContact(contact) || contact.getValue().profileId() == NO_PROFILE_ID || contact.getValue().profileId() == OWN_PROFILE_ID));\n\n\t\t\treturn true;\n\t\t});\n\t\txContextMenu.addToNode(contactTreeTableView);\n\t}\n\n\tprivate void createStateContextMenu()\n\t{\n\t\tvar availableItem = createStateMenuItem(Availability.AVAILABLE);\n\t\tvar awayItem = createStateMenuItem(Availability.AWAY);\n\t\tvar busyItem = createStateMenuItem(Availability.BUSY);\n\n\t\tvar contextMenu = new ContextMenu(availableItem, awayItem, busyItem);\n\t\townContactState.setOnContextMenuRequested(event -> {\n\t\t\tcontextMenu.show(ownContactState, event.getScreenX(), event.getScreenY());\n\t\t\tevent.consume();\n\t\t});\n\t\tUiUtils.setOnPrimaryMouseClicked(ownContactState, event -> contextMenu.show(ownContactState, event.getScreenX(), event.getScreenY()));\n\t}\n\n\tprivate void createLocationTableContextMenu()\n\t{\n\t\tvar chatItem = new MenuItem(bundle.getString(\"contact-view.action.chat\"));\n\t\tchatItem.setId(CHAT_MENU_ID);\n\t\tchatItem.setGraphic(new FontIcon(MaterialDesignM.MESSAGE));\n\t\tchatItem.setOnAction(event -> {\n\t\t\tvar location = (Location) event.getSource();\n\t\t\tstartChat(location.getLocationIdentifier());\n\t\t});\n\n\t\tvar connectItem = new MenuItem(bundle.getString(\"contact-view.action.connect\"));\n\t\tconnectItem.setId(CONNECT_MENU_ID);\n\t\tconnectItem.setGraphic(new FontIcon(MaterialDesignC.CONNECTION));\n\t\tconnectItem.setOnAction(event -> {\n\t\t\tvar location = (Location) event.getSource();\n\t\t\tconnectionClient.connect(location.getLocationIdentifier(), -1)\n\t\t\t\t\t.subscribe();\n\t\t});\n\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tcopyLinkItem.setOnAction(event -> {\n\t\t\tvar location = (Location) event.getSource();\n\t\t\tClipboardUtils.copyTextToClipboard(location.getLocationIdentifier().toString());\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<Location>(chatItem, connectItem, copyLinkItem);\n\t\txContextMenu.setOnShowing((contextMenu, location) -> {\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> CHAT_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(location == null || location.getId() == OWN_LOCATION_ID));\n\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> CONNECT_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(location == null || location.getId() == OWN_LOCATION_ID || location.isConnected()));\n\n\t\t\treturn location != null;\n\t\t});\n\t\txContextMenu.addToNode(locationTableView);\n\t}\n\n\tprivate MenuItem createStateMenuItem(Availability availability)\n\t{\n\t\tvar menuItem = new MenuItem(availability.toString());\n\t\tmenuItem.setGraphic(AvailabilityCellUtil.updateAvailability(null, availability));\n\t\tmenuItem.setOnAction(_ -> configClient.changeAvailability(availability).subscribe());\n\t\treturn menuItem;\n\t}\n\n\tprivate boolean isSubContact(TreeItem<Contact> contact)\n\t{\n\t\treturn contact.getParent() != treeRoot && contact.isLeaf();\n\t}\n\n\tprivate void selectOwnContactImage(ActionEvent event)\n\t{\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"main.select-avatar\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tChooserUtils.setSupportedLoadImageFormats(fileChooser);\n\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\tif (selectedFile != null && selectedFile.canRead())\n\t\t{\n\t\t\tidentityClient.uploadIdentityImage(OWN_IDENTITY_ID, selectedFile)\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tprivate void startChat(Contact contact)\n\t{\n\t\tif (contact.profileId() != NO_PROFILE_ID)\n\t\t{\n\t\t\tprofileClient.findById(contact.profileId())\n\t\t\t\t\t.doOnSuccess(profile -> {\n\t\t\t\t\t\t\t\tassert profile != null;\n\t\t\t\t\t\t\t\tprofile.getLocations().stream()\n\t\t\t\t\t\t\t\t\t\t.filter(Location::isConnected).min(Comparator.comparing(Location::getAvailability))\n\t\t\t\t\t\t\t\t\t\t.ifPresent(location -> windowManager.openMessaging(location.getLocationIdentifier()));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t)\n\t\t\t\t\t.subscribe();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tstartDistantChat(contact);\n\t\t}\n\t}\n\n\tprivate void startChat(LocationIdentifier locationIdentifier)\n\t{\n\t\twindowManager.openMessaging(locationIdentifier);\n\t}\n\n\tprivate void startDistantChat(Contact contact)\n\t{\n\t\tidentityClient.findById(contact.identityId())\n\t\t\t\t.doOnSuccess(identity -> {\n\t\t\t\t\tassert identity != null;\n\t\t\t\t\twindowManager.openMessaging(identity.getGxsId());\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (contactNotificationDisposable != null && !contactNotificationDisposable.isDisposed())\n\t\t{\n\t\t\tcontactNotificationDisposable.dispose();\n\t\t}\n\t\tif (availabilityNotificationDisposable != null && !availabilityNotificationDisposable.isDisposed())\n\t\t{\n\t\t\tavailabilityNotificationDisposable.dispose();\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvent(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof IdentityUri identityUri)\n\t\t{\n\t\t\tidentityClient.findByGxsId(identityUri.gxsId()).collectList()\n\t\t\t\t\t.doOnSuccess(identities -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert identities != null;\n\t\t\t\t\t\tif (identities.isEmpty())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tUiUtils.showAlert(WARNING, bundle.getString(\"contact-view.open.identity-not-found\"));\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar identity = identities.getFirst();\n\n\t\t\t\t\t\t\tif (identity.getId() == OWN_IDENTITY_ID)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// This is our own identity.\n\t\t\t\t\t\t\t\tdisplayOwnContact();\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tcontactObservableList.stream()\n\t\t\t\t\t\t\t\t\t.filter(contact -> contact.getValue().identityId() == identity.getId())\n\t\t\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t\t\t.ifPresentOrElse(contact -> {\n\t\t\t\t\t\t\t\t\t\tcontactTreeTableView.getSelectionModel().select(contact);\n\t\t\t\t\t\t\t\t\t\tscrollToSelectedContact();\n\t\t\t\t\t\t\t\t\t}, () -> UiUtils.showAlert(WARNING, bundle.getString(\"contact-view.open.identity-not-found\")));\n\t\t\t\t\t\t}\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t}\n\t\telse if (event.uri() instanceof ProfileUri profileUri)\n\t\t{\n\t\t\tprofileClient.findByPgpIdentifier(profileUri.hash(), true).collectList()\n\t\t\t\t\t.doOnSuccess(profiles -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert profiles != null;\n\t\t\t\t\t\tif (profiles.isEmpty())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tUiUtils.showAlert(WARNING, bundle.getString(\"contact-view.open.profile-not-found\"));\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar profile = profiles.getFirst();\n\t\t\t\t\t\t\tif (profile.getId() == OWN_IDENTITY_ID)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// This is our own profile.\n\t\t\t\t\t\t\t\tdisplayOwnContact();\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tcontactObservableList.stream()\n\t\t\t\t\t\t\t\t\t.filter(contact -> contact.getValue().profileId() == profile.getId())\n\t\t\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t\t\t.ifPresentOrElse(contact -> {\n\t\t\t\t\t\t\t\t\t\tcontactTreeTableView.getSelectionModel().select(contact);\n\t\t\t\t\t\t\t\t\t\tscrollToSelectedContact();\n\t\t\t\t\t\t\t\t\t}, () -> UiUtils.showAlert(WARNING, bundle.getString(\"contact-view.open.profile-not-found\")));\n\t\t\t\t\t\t}\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/contact/LocationRow.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TableRow;\n\nimport java.util.ResourceBundle;\nimport java.util.regex.Pattern;\n\nclass LocationRow extends TableRow<Location>\n{\n\tprivate static final Pattern RETROSHARE_VERSION_DETECTOR = Pattern.compile(\"^\\\\d.*$\");\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@Override\n\tprotected void updateItem(Location item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tTooltipUtils.uninstall(this);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar sb = new StringBuilder();\n\t\t\tsb.append(bundle.getString(\"contact-view.information.location.id\"));\n\t\t\tsb.append(\" \");\n\t\t\tsb.append(item.getLocationIdentifier().toString());\n\t\t\tif (item.hasVersion())\n\t\t\t{\n\t\t\t\tsb.append(\"\\n\");\n\t\t\t\tsb.append(bundle.getString(\"contact-view.information.location.version\"));\n\t\t\t\tsb.append(\" \");\n\t\t\t\t// Retroshare only sends the version so we prefix it with its name\n\t\t\t\tif (RETROSHARE_VERSION_DETECTOR.matcher(item.getVersion()).matches())\n\t\t\t\t{\n\t\t\t\t\tsb.append(\"Retroshare \");\n\t\t\t\t}\n\t\t\t\tsb.append(item.getVersion());\n\t\t\t}\n\t\t\tTooltipUtils.install(this, sb.toString());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/debug/DebugRequesterWindowController.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.debug;\n\nimport io.xeres.ui.controller.WindowController;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.ComboBox;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.io.IOException;\n\n@Component\n@FxmlView(value = \"/view/debug/debug_requester_view.fxml\")\npublic class DebugRequesterWindowController implements WindowController\n{\n\t@FXML\n\tprivate ComboBox<String> comboBox;\n\n\t@Override\n\tpublic void initialize() throws IOException\n\t{\n\t\tcomboBox.getSelectionModel().selectFirst();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileAddDownloadViewWindowController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.rest.file.AddDownloadRequest;\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.ui.client.FileClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.ReadOnlyTextField;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/file/add_download.fxml\")\npublic class FileAddDownloadViewWindowController implements WindowController\n{\n\t@FXML\n\tprivate ReadOnlyTextField name;\n\n\t@FXML\n\tprivate ReadOnlyTextField size;\n\n\t@FXML\n\tprivate ReadOnlyTextField hash;\n\n\t@FXML\n\tprivate Button downloadButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\tprivate final FileClient fileClient;\n\tprivate final ResourceBundle bundle;\n\n\tpublic FileAddDownloadViewWindowController(FileClient fileClient, ResourceBundle bundle)\n\t{\n\t\tthis.fileClient = fileClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\n\t\tPlatform.runLater(this::handleArgument);\n\t}\n\n\tprivate void handleArgument()\n\t{\n\t\tvar args = (AddDownloadRequest) UiUtils.getUserData(name);\n\t\tif (args == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing user data\");\n\t\t}\n\n\t\tname.setText(args.name());\n\t\tsize.setText(ByteUnitUtils.fromBytes(args.size()));\n\t\tTooltipUtils.install(size, MessageFormat.format(bundle.getString(\"download-add.bytes\"), args.size()));\n\t\thash.setText(args.hash().toString());\n\n\t\tdownloadButton.setOnAction(_ -> fileClient.download(args.name(),\n\t\t\t\t\t\targs.hash(),\n\t\t\t\t\t\targs.size(),\n\t\t\t\t\t\targs.locationIdentifier())\n\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(name)))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.subscribe());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileDownloadViewController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.rest.file.FileProgress;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.FileClient;\nimport io.xeres.ui.client.SettingsClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.TabActivation;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.control.SeparatorMenuItem;\nimport javafx.scene.control.TableColumn;\nimport javafx.scene.control.TableView;\nimport javafx.scene.control.cell.ProgressBarTableCell;\nimport javafx.scene.control.cell.PropertyValueFactory;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignF;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\n\nimport java.nio.file.Paths;\nimport java.util.ResourceBundle;\nimport java.util.concurrent.ScheduledExecutorService;\n\nimport static io.xeres.ui.controller.file.FileProgressDisplay.State.*;\nimport static javafx.scene.control.Alert.AlertType.ERROR;\n\n@Component\n@FxmlView(value = \"/view/file/download.fxml\")\npublic class FileDownloadViewController implements Controller, TabActivation\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileDownloadViewController.class);\n\n\tprivate static final int UPDATE_IN_SECONDS = 2;\n\n\tprivate static final String REMOVE_MENU_ID = \"remove\";\n\tprivate static final String OPEN_MENU_ID = \"open\";\n\tprivate static final String SHOW_IN_FOLDER_MENU_ID = \"showInFolder\";\n\n\tprivate final FileClient fileClient;\n\tprivate final SettingsClient settingsClient;\n\tprivate final ResourceBundle bundle;\n\n\t@FXML\n\tprivate TableView<FileProgressDisplay> downloadTableView;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, String> tableName;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, String> tableState;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, Double> tableProgress;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, Long> tableTotalSize;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, String> tableHash;\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate boolean wasRunning;\n\n\tpublic FileDownloadViewController(FileClient fileClient, SettingsClient settingsClient, ResourceBundle bundle)\n\t{\n\t\tthis.fileClient = fileClient;\n\t\tthis.settingsClient = settingsClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tcreateContextMenu();\n\n\t\ttableName.setCellValueFactory(new PropertyValueFactory<>(\"name\"));\n\t\ttableState.setCellValueFactory(new PropertyValueFactory<>(\"state\"));\n\t\ttableProgress.setCellFactory(ProgressBarTableCell.forTableColumn());\n\t\ttableProgress.setCellValueFactory(new PropertyValueFactory<>(\"progress\"));\n\t\ttableTotalSize.setCellFactory(_ -> new FileProgressSizeCell());\n\t\ttableTotalSize.setCellValueFactory(new PropertyValueFactory<>(\"totalSize\"));\n\t\ttableHash.setCellValueFactory(new PropertyValueFactory<>(\"hash\"));\n\t}\n\n\tprivate void start()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(() -> fileClient.getDownloads().collectMap(FileProgress::hash)\n\t\t\t\t\t\t.doOnSuccess(incomingProgresses -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert incomingProgresses != null;\n\t\t\t\t\t\t\tvar it = downloadTableView.getItems().iterator();\n\t\t\t\t\t\t\twhile (it.hasNext())\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvar currentProgress = it.next();\n\t\t\t\t\t\t\t\tvar incomingProgress = incomingProgresses.get(currentProgress.getHash());\n\t\t\t\t\t\t\t\tif (incomingProgress != null)\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tvar newProgress = (double) incomingProgress.currentSize() / incomingProgress.totalSize();\n\t\t\t\t\t\t\t\t\tvar newState = getState(currentProgress, incomingProgress, newProgress);\n\t\t\t\t\t\t\t\t\tif (currentProgress.getState() != REMOVING)\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tcurrentProgress.setState(newState);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tcurrentProgress.setProgress(newProgress);\n\t\t\t\t\t\t\t\t\tincomingProgresses.remove(incomingProgress.hash());\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tit.remove();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tincomingProgresses.forEach((_, fileProgress) -> downloadTableView.getItems().add(new FileProgressDisplay(fileProgress.id(), fileProgress.name(), fileProgress.completed() ? DONE : SEARCHING, 0.0, fileProgress.totalSize(), fileProgress.hash())));\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe(),\n\t\t\t\t1,\n\t\t\t\tUPDATE_IN_SECONDS);\n\t}\n\n\tprivate static FileProgressDisplay.State getState(FileProgressDisplay currentProgress, FileProgress incomingProgress, double newProgress)\n\t{\n\t\tif (incomingProgress.completed())\n\t\t{\n\t\t\treturn DONE;\n\t\t}\n\t\tif (currentProgress.getProgress() != 0.0 && newProgress != currentProgress.getProgress()) // The first check is to not show transferring when resuming after a restart\n\t\t{\n\t\t\treturn TRANSFERRING;\n\t\t}\n\t\treturn SEARCHING;\n\t}\n\n\tpublic void stop()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\tpublic void resume()\n\t{\n\t\tif (wasRunning)\n\t\t{\n\t\t\tstart();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void activate()\n\t{\n\t\tstart();\n\t\twasRunning = true;\n\t}\n\n\t@Override\n\tpublic void deactivate()\n\t{\n\t\tstop();\n\t\twasRunning = false;\n\t}\n\n\tprivate void createContextMenu()\n\t{\n\t\tvar removeItem = new MenuItem(bundle.getString(\"remove\"));\n\t\tremoveItem.setId(REMOVE_MENU_ID);\n\t\tremoveItem.setGraphic(new FontIcon(MaterialDesignF.FILE_REMOVE));\n\t\tremoveItem.setOnAction(event -> {\n\t\t\tif (event.getSource() instanceof FileProgressDisplay fileProgressDisplay)\n\t\t\t{\n\t\t\t\tlog.debug(\"Removing download of file {}\", fileProgressDisplay.getName());\n\t\t\t\tfileClient.removeDownload(fileProgressDisplay.getId())\n\t\t\t\t\t\t.doOnSuccess(_ -> fileProgressDisplay.setState(REMOVING))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tvar openItem = new MenuItem(bundle.getString(\"open\"));\n\t\topenItem.setId(OPEN_MENU_ID);\n\t\topenItem.setGraphic(new FontIcon(MaterialDesignF.FILE_EYE));\n\t\topenItem.setOnAction(event -> {\n\t\t\tif (event.getSource() instanceof FileProgressDisplay fileProgressDisplay)\n\t\t\t{\n\t\t\t\tlog.debug(\"Opening file {}\", fileProgressDisplay.getName());\n\t\t\t\tsettingsClient.getSettings()\n\t\t\t\t\t\t.doOnSuccess(settings -> {\n\t\t\t\t\t\t\tassert settings != null;\n\t\t\t\t\t\t\tvar file = Paths.get(settings.getIncomingDirectory(), fileProgressDisplay.getName()).toFile();\n\t\t\t\t\t\t\ttry\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tOsUtils.shellOpen(file);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcatch (IllegalStateException e)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\t\t\t\t\tUiUtils.showAlert(ERROR, bundle.getString(\"download-view.open-error\") + \" \" + e.getMessage() + \".\");\n\t\t\t\t\t\t\t\t\tlog.error(\"Failed to open the file\", e);\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tvar showInExplorerItem = new MenuItem(bundle.getString(\"download-view.show-in-folder\"));\n\t\tshowInExplorerItem.setId(SHOW_IN_FOLDER_MENU_ID);\n\t\tshowInExplorerItem.setGraphic(new FontIcon(MaterialDesignF.FOLDER_OPEN));\n\t\tshowInExplorerItem.setOnAction(event -> {\n\t\t\tif (event.getSource() instanceof FileProgressDisplay fileProgressDisplay)\n\t\t\t{\n\t\t\t\tlog.debug(\"Showing file {} in folder\", fileProgressDisplay.getName());\n\t\t\t\tsettingsClient.getSettings()\n\t\t\t\t\t\t.doOnSuccess(settings -> {\n\t\t\t\t\t\t\tassert settings != null;\n\t\t\t\t\t\t\tvar file = Paths.get(settings.getIncomingDirectory(), fileProgressDisplay.getName()).toFile();\n\t\t\t\t\t\t\ttry\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tOsUtils.showInFolder(file);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcatch (IllegalStateException e)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\t\t\t\t\tUiUtils.showAlert(ERROR, bundle.getString(\"download-view.show-error\") + \" \" + e.getMessage() + \".\");\n\t\t\t\t\t\t\t\t\tlog.error(\"Failed to show the file in folder\", e);\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<FileProgressDisplay>(openItem, showInExplorerItem, new SeparatorMenuItem(), removeItem);\n\t\txContextMenu.addToNode(downloadTableView);\n\t\txContextMenu.setOnShowing((contextMenu, file) -> {\n\t\t\tif (file == null)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\t// Disable \"Remove\" if the file is already being removed\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> REMOVE_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(file.getState() == REMOVING));\n\n\t\t\t// Disable Open and Show in Folder unless the file is fully downloaded\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> SHOW_IN_FOLDER_MENU_ID.equals(menuItem.getId()) || OPEN_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.forEach(menuItem -> menuItem.setDisable(file.getState() != DONE));\n\n\t\t\treturn true;\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileMainController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.TabActivation;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.support.uri.SearchUri;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.TabPane;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\n\n@Component\n@FxmlView(value = \"/view/file/main.fxml\")\npublic class FileMainController implements Controller\n{\n\t@FXML\n\tprivate TabPane tabPane;\n\n\t@FXML\n\tprivate FileSearchViewController fileSearchViewController;\n\n\t@FXML\n\tprivate FileDownloadViewController fileDownloadViewController;\n\n\t@FXML\n\tprivate FileUploadViewController fileUploadViewController;\n\n\t@FXML\n\tprivate FileTrendViewController fileTrendViewController;\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\ttabPane.getSelectionModel().selectedItemProperty()\n\t\t\t\t.addListener((_, oldValue, newValue) -> Platform.runLater(() -> {\n\t\t\t\t\tidToController(oldValue.getId()).deactivate();\n\t\t\t\t\tidToController(newValue.getId()).activate();\n\t\t\t\t}));\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvents(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof SearchUri _)\n\t\t{\n\t\t\ttabPane.getSelectionModel().select(0);\n\t\t}\n\t}\n\n\tprivate TabActivation idToController(String id)\n\t{\n\t\treturn switch (id)\n\t\t{\n\t\t\tcase \"search\" -> fileSearchViewController;\n\t\t\tcase \"downloads\" -> fileDownloadViewController;\n\t\t\tcase \"uploads\" -> fileUploadViewController;\n\t\t\tcase \"trends\" -> fileTrendViewController;\n\t\t\tdefault -> throw new IllegalStateException(\"Unexpected value: \" + id);\n\t\t};\n\t}\n\n\tpublic void resume()\n\t{\n\t\tfileDownloadViewController.resume();\n\t\tfileUploadViewController.resume();\n\t}\n\n\tpublic void suspend()\n\t{\n\t\tfileDownloadViewController.stop();\n\t\tfileUploadViewController.stop();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileProgressDisplay.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\nimport javafx.beans.property.SimpleDoubleProperty;\nimport javafx.beans.property.SimpleLongProperty;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.property.SimpleStringProperty;\n\nimport java.util.ResourceBundle;\n\n// Public modifier needed by JavaFX\npublic class FileProgressDisplay\n{\n\tpublic enum State implements I18nEnum\n\t{\n\t\tSEARCHING,\n\t\tTRANSFERRING,\n\t\tREMOVING,\n\t\tDONE;\n\n\t\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t\t@Override\n\t\tpublic String toString()\n\t\t{\n\t\t\treturn bundle.getString(getMessageKey(this));\n\t\t}\n\t}\n\n\tprivate final long id;\n\tprivate final SimpleStringProperty name;\n\tprivate final SimpleObjectProperty<State> state;\n\tprivate final SimpleDoubleProperty progress;\n\tprivate final SimpleLongProperty totalSize;\n\tprivate final SimpleStringProperty hash;\n\n\tpublic FileProgressDisplay(long id, String name, State state, double progress, long totalSize, String hash)\n\t{\n\t\tthis.id = id;\n\t\tthis.name = new SimpleStringProperty(name);\n\t\tthis.state = new SimpleObjectProperty<>(state);\n\t\tthis.progress = new SimpleDoubleProperty(progress);\n\t\tthis.totalSize = new SimpleLongProperty(totalSize);\n\t\tthis.hash = new SimpleStringProperty(hash);\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleStringProperty nameProperty()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name.set(name);\n\t}\n\n\tpublic State getState()\n\t{\n\t\treturn state.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleObjectProperty<State> stateProperty()\n\t{\n\t\treturn state;\n\t}\n\n\tpublic void setState(State state)\n\t{\n\t\tthis.state.set(state);\n\t}\n\n\tpublic double getProgress()\n\t{\n\t\treturn progress.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleDoubleProperty progressProperty()\n\t{\n\t\treturn progress;\n\t}\n\n\tpublic void setProgress(double progress)\n\t{\n\t\tthis.progress.set(progress);\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic long getTotalSize()\n\t{\n\t\treturn totalSize.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleLongProperty totalSizeProperty()\n\t{\n\t\treturn totalSize;\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic void setTotalSize(long totalSize)\n\t{\n\t\tthis.totalSize.set(totalSize);\n\t}\n\n\tpublic String getHash()\n\t{\n\t\treturn hash.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleStringProperty hashProperty()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic void setHash(String hash)\n\t{\n\t\tthis.hash.set(hash);\n\t}\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileProgressSizeCell.java",
    "content": "package io.xeres.ui.controller.file;\n\nimport io.xeres.common.util.ByteUnitUtils;\nimport javafx.scene.control.TableCell;\n\nclass FileProgressSizeCell extends TableCell<FileProgressDisplay, Long>\n{\n\t@Override\n\tprotected void updateItem(Long value, boolean empty)\n\t{\n\t\tsuper.updateItem(value, empty);\n\t\tsetText(empty ? null : ByteUnitUtils.fromBytes(value));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileResult.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.file.FileType;\n\nimport java.util.Objects;\n\npublic record FileResult(\n\t\tString name,\n\t\tlong size,\n\t\tFileType type,\n\t\tString hash\n)\n{\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (this == o)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tif (o == null || getClass() != o.getClass())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tvar file = (FileResult) o;\n\t\treturn Objects.equals(hash, file.hash);\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hashCode(hash);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileResultNameCell.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.file.FileType;\nimport javafx.scene.Node;\nimport javafx.scene.control.TableCell;\n\nimport java.util.function.Function;\n\nclass FileResultNameCell extends TableCell<FileResult, FileResult>\n{\n\tprivate final Function<FileType, Node> converter;\n\n\tpublic FileResultNameCell(Function<FileType, Node> converter)\n\t{\n\t\tsuper();\n\t\tthis.converter = converter;\n\t}\n\n\t@Override\n\tprotected void updateItem(FileResult item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.name());\n\t\tsetGraphic(empty ? null : converter.apply(item.type()));\n\t\tsetGraphicTextGap(4.0);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileResultSizeCell.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.util.ByteUnitUtils;\nimport javafx.scene.control.TableCell;\n\nclass FileResultSizeCell extends TableCell<FileResult, Long>\n{\n\t@Override\n\tprotected void updateItem(Long value, boolean empty)\n\t{\n\t\tsuper.updateItem(value, empty);\n\t\tsetText(empty ? null : ByteUnitUtils.fromBytes(value));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileResultView.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.file.FileType;\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.ui.client.FileClient;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.uri.FileUri;\nimport javafx.application.Platform;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.property.SimpleStringProperty;\nimport javafx.concurrent.Task;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.geometry.Pos;\nimport javafx.scene.Node;\nimport javafx.scene.control.*;\nimport javafx.scene.layout.StackPane;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignF;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignL;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\npublic class FileResultView extends Tab\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileResultView.class);\n\n\tprivate static final String DOWNLOAD_MENU_ID = \"download\";\n\tprivate static final String COPY_LINK_MENU_ID = \"copyLink\";\n\n\tpublic static final int FILE_ICON_SIZE = 24;\n\n\tprivate final FileClient fileClient;\n\tprivate final ResourceBundle bundle;\n\n\tprivate final int searchId;\n\n\t@FXML\n\tprivate TableView<FileResult> filesTableView;\n\n\t@FXML\n\tprivate TableColumn<FileResult, FileResult> tableName;\n\n\t@FXML\n\tprivate TableColumn<FileResult, Long> tableSize;\n\n\t@FXML\n\tprivate TableColumn<FileResult, String> tableType;\n\n\t@FXML\n\tprivate TableColumn<FileResult, String> tableHash;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\tpublic FileResultView(FileClient fileClient, String text, int searchId)\n\t{\n\t\tsuper(text);\n\t\tthis.fileClient = fileClient;\n\t\tthis.searchId = searchId;\n\n\t\tbundle = I18nUtils.getBundle();\n\n\t\tvar loader = new FXMLLoader(FileResultView.class.getResource(\"/view/custom/file_results_view.fxml\"), bundle);\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\t@FXML\n\tprivate void initialize()\n\t{\n\t\tcreateFilesTableViewContextMenu();\n\n\t\ttableName.setCellFactory(_ -> new FileResultNameCell(this::getGraphicForType));\n\t\ttableName.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue()));\n\t\ttableSize.setCellFactory(_ -> new FileResultSizeCell());\n\t\ttableSize.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().size()));\n\t\ttableType.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().type().toString()));\n\t\ttableHash.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().hash()));\n\n\t\tshowProgress();\n\t}\n\n\tpublic int getSearchId()\n\t{\n\t\treturn searchId;\n\t}\n\n\tpublic void addResult(String name, long size, String hash)\n\t{\n\t\tvar file = new FileResult(name, size, FileType.getTypeByExtension(name), hash);\n\n\t\tif (!filesTableView.getItems().contains(file))\n\t\t{\n\t\t\tfilesTableView.getItems().add(file);\n\t\t}\n\t}\n\n\tprivate Node getGraphicForType(FileType type)\n\t{\n\t\tvar pane = new StackPane(new FontIcon(getIconCodeForType(type)));\n\t\tpane.setPrefWidth(FILE_ICON_SIZE);\n\t\tpane.setPrefHeight(FILE_ICON_SIZE);\n\t\tpane.setAlignment(Pos.CENTER);\n\t\treturn pane;\n\t}\n\n\tprivate static String getIconCodeForType(FileType type)\n\t{\n\t\treturn switch (type)\n\t\t{\n\t\t\tcase AUDIO -> \"mdi2f-file-music\";\n\t\t\tcase VIDEO -> \"mdi2f-file-video\";\n\t\t\tcase PICTURE -> \"mdi2f-file-image\";\n\t\t\tcase DOCUMENT -> \"mdi2f-file-document\";\n\t\t\tcase ARCHIVE -> \"mdi2f-file-cabinet\";\n\t\t\tcase PROGRAM -> \"mdi2a-application\";\n\t\t\tcase COLLECTION -> \"mdi2l-layers\";\n\t\t\tcase SUBTITLES -> \"mdi2c-closed-caption\";\n\t\t\tcase DIRECTORY, ANY -> \"mdi2f-file\";\n\t\t};\n\t}\n\n\tprivate void createFilesTableViewContextMenu()\n\t{\n\t\tvar downloadItem = new MenuItem(bundle.getString(\"download\"));\n\t\tdownloadItem.setId(DOWNLOAD_MENU_ID);\n\t\tdownloadItem.setGraphic(new FontIcon(MaterialDesignF.FILE_DOWNLOAD));\n\t\tdownloadItem.setOnAction(event -> {\n\t\t\tif (event.getSource() instanceof FileResult file)\n\t\t\t{\n\t\t\t\tlog.debug(\"Downloading file {}\", file.name());\n\t\t\t\tfileClient.download(file.name(), Sha1Sum.fromString(file.hash()), file.size(), null)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy-link\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tcopyLinkItem.setOnAction(event -> {\n\t\t\tif (event.getSource() instanceof FileResult file)\n\t\t\t{\n\t\t\t\tvar fileUri = new FileUri(file.name(), file.size(), Sha1Sum.fromString(file.hash()));\n\t\t\t\tClipboardUtils.copyTextToClipboard(fileUri.toUriString());\n\t\t\t}\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<FileResult>(downloadItem, new SeparatorMenuItem(), copyLinkItem);\n\t\txContextMenu.addToNode(filesTableView);\n\t\txContextMenu.setOnShowing((_, file) -> file != null);\n\t}\n\n\tprivate void showProgress()\n\t{\n\t\tvar task = new Task<Void>()\n\t\t{\n\t\t\t@Override\n\t\t\tprotected Void call() throws Exception\n\t\t\t{\n\t\t\t\tfor (var d = 0.0; d <= 1.0; d += 0.001)\n\t\t\t\t{\n\t\t\t\t\tThread.sleep(20);\n\t\t\t\t\tdouble finalD = d;\n\t\t\t\t\tPlatform.runLater(() -> progressBar.setProgress(finalD));\n\t\t\t\t}\n\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\tprogressBar.setProgress(1.0);\n\t\t\t\t\tfilesTableView.setPlaceholder(new Label(bundle.getString(\"no-results\")));\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t}\n\t\t};\n\t\tThread.ofVirtual().name(\"Search Progress Indicator Task\").start(task);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileSearchViewController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.ui.client.FileClient;\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.TabActivation;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.uri.SearchUri;\nimport io.xeres.ui.support.util.TextInputControlUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.control.Tab;\nimport javafx.scene.control.TabPane;\nimport javafx.scene.control.TextField;\nimport javafx.scene.input.KeyCode;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignL;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/file/search.fxml\")\npublic class FileSearchViewController implements Controller, TabActivation\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(FileSearchViewController.class);\n\n\tprivate static final String COPY_LINK_MENU_ID = \"copyLink\";\n\n\tprivate final FileClient fileClient;\n\tprivate final ResourceBundle bundle;\n\n\t@FXML\n\tprivate TextField search;\n\n\t@FXML\n\tprivate TabPane resultTabPane;\n\n\tprivate final NotificationClient notificationClient;\n\tprivate Disposable notificationDisposable;\n\n\tpublic FileSearchViewController(FileClient fileClient, NotificationClient notificationClient, ResourceBundle bundle)\n\t{\n\t\tthis.fileClient = fileClient;\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tTextInputControlUtils.addEnhancedInputContextMenu(search, null, null);\n\t\tsearch.setOnKeyPressed(event -> {\n\t\t\tif (event.getCode() == KeyCode.ENTER)\n\t\t\t{\n\t\t\t\tvar searchText = search.getText();\n\t\t\t\tlog.debug(\"Searching for: {}\", searchText);\n\t\t\t\tsearch.clear();\n\t\t\t\tfileClient.search(searchText)\n\t\t\t\t\t\t.doOnSuccess(fileSearchResponse -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert fileSearchResponse != null;\n\t\t\t\t\t\t\tvar fileResultView = new FileResultView(fileClient, searchText, fileSearchResponse.id());\n\t\t\t\t\t\t\tresultTabPane.getTabs().add(fileResultView);\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t});\n\n\t\tcreateContextMenu();\n\t\tsetupFileSearchNotifications();\n\t}\n\n\tprivate void addToResultTab(int requestId, String name, long size, String hash)\n\t{\n\t\tresultTabPane.getTabs().stream()\n\t\t\t\t.filter(tab -> ((FileResultView) tab).getSearchId() == requestId)\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresent(tab -> ((FileResultView) tab).addResult(name, size, hash));\n\t}\n\n\tprivate void setupFileSearchNotifications()\n\t{\n\t\tnotificationDisposable = notificationClient.getFileSearchNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tif (sse.data() != null && sse.data().name() != null)\n\t\t\t\t\t{\n\t\t\t\t\t\taddToResultTab(sse.data().requestId(), sse.data().name(), sse.data().size(), sse.data().hash());\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (notificationDisposable != null && !notificationDisposable.isDisposed())\n\t\t{\n\t\t\tnotificationDisposable.dispose();\n\t\t}\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvents(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof SearchUri(String keywords))\n\t\t{\n\t\t\tsearch.setText(keywords);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void activate()\n\t{\n\n\t}\n\n\t@Override\n\tpublic void deactivate()\n\t{\n\n\t}\n\n\tprivate void createContextMenu()\n\t{\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy-link\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tcopyLinkItem.setOnAction(event -> {\n\t\t\tvar fileResultView = (FileResultView) event.getSource();\n\t\t\tvar searchUri = new SearchUri(fileResultView.getText());\n\t\t\tClipboardUtils.copyTextToClipboard(searchUri.toUriString());\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<Tab>(copyLinkItem);\n\t\txContextMenu.addToNode(resultTabPane);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileTrendViewController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.TabActivation;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.property.SimpleStringProperty;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.TableColumn;\nimport javafx.scene.control.TableView;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport java.time.Instant;\nimport java.util.LinkedList;\n\n@Component\n@FxmlView(value = \"/view/file/trend.fxml\")\npublic class FileTrendViewController implements Controller, TabActivation\n{\n\tprivate static final String NAME_CONTAINS_ALL = \"NAME CONTAINS ALL \";\n\tprivate static final int MAXIMUM_BACKLOG = 300;\n\tprivate static final int MAXIMUM_DUPLICATE_SEARCH = 5;\n\n\n\tprivate final NotificationClient notificationClient;\n\tprivate Disposable notificationDisposable;\n\n\tprivate final ObservableList<TrendResult> trendResult = FXCollections.observableList(new LinkedList<>());\n\n\t@FXML\n\tprivate TableView<TrendResult> trendTableView;\n\n\t// XXX: make sure the table is NOT sortable!!\n\n\t@FXML\n\tprivate TableColumn<TrendResult, String> tableFrom;\n\n\t@FXML\n\tprivate TableColumn<TrendResult, String> tableTerms;\n\n\t@FXML\n\tprivate TableColumn<TrendResult, Instant> tableTime;\n\n\tpublic FileTrendViewController(NotificationClient notificationClient)\n\t{\n\t\tthis.notificationClient = notificationClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\ttrendTableView.setItems(trendResult);\n\n\t\ttableTerms.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().keywords()));\n\t\ttableFrom.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().senderName()));\n\t\ttableTime.setCellFactory(param -> new TimeCell());\n\t\ttableTime.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().when()));\n\n\t\tsetupFileTrendNotifications();\n\t}\n\n\tprivate void setupFileTrendNotifications()\n\t{\n\t\tnotificationDisposable = notificationClient.getFileTrendNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tassert sse.data() != null;\n\t\t\t\t\tvar keywords = sse.data().keywords();\n\n\t\t\t\t\tif (keywords.startsWith(NAME_CONTAINS_ALL))\n\t\t\t\t\t{\n\t\t\t\t\t\tkeywords = keywords.substring(NAME_CONTAINS_ALL.length());\n\t\t\t\t\t}\n\n\t\t\t\t\t// Don't add if it's already in the first few\n\t\t\t\t\t// entries. This avoids duplicates.\n\t\t\t\t\tif (isAlreadyTrending(keywords))\n\t\t\t\t\t{\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\ttrendResult.addFirst(new TrendResult(keywords, sse.data().senderName(), Instant.now()));\n\t\t\t\t\tif (trendTableView.getItems().size() > MAXIMUM_BACKLOG)\n\t\t\t\t\t{\n\t\t\t\t\t\ttrendTableView.getItems().removeLast();\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate boolean isAlreadyTrending(String keywords)\n\t{\n\t\treturn trendResult.stream()\n\t\t\t\t.limit(MAXIMUM_DUPLICATE_SEARCH)\n\t\t\t\t.anyMatch(result -> result.keywords().equals(keywords));\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (notificationDisposable != null && !notificationDisposable.isDisposed())\n\t\t{\n\t\t\tnotificationDisposable.dispose();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void activate()\n\t{\n\n\t}\n\n\t@Override\n\tpublic void deactivate()\n\t{\n\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/FileUploadViewController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport io.xeres.common.rest.file.FileProgress;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.ui.client.FileClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.TabActivation;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.TableColumn;\nimport javafx.scene.control.TableView;\nimport javafx.scene.control.cell.PropertyValueFactory;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.ScheduledExecutorService;\n\nimport static io.xeres.ui.controller.file.FileProgressDisplay.State.TRANSFERRING;\n\n@Component\n@FxmlView(value = \"/view/file/upload.fxml\")\npublic class FileUploadViewController implements Controller, TabActivation\n{\n\tprivate static final int UPDATE_IN_SECONDS = 6; // Longer time to avoid flickering when switching between chunk requests\n\n\tprivate final FileClient fileClient;\n\n\t@FXML\n\tprivate TableView<FileProgressDisplay> uploadTableView;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, String> tableName;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, Long> tableTotalSize;\n\n\t@FXML\n\tprivate TableColumn<FileProgressDisplay, String> tableHash;\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate boolean wasRunning;\n\n\tpublic FileUploadViewController(FileClient fileClient)\n\t{\n\t\tthis.fileClient = fileClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\ttableName.setCellValueFactory(new PropertyValueFactory<>(\"name\"));\n\t\ttableTotalSize.setCellFactory(_ -> new FileProgressSizeCell());\n\t\ttableTotalSize.setCellValueFactory(new PropertyValueFactory<>(\"totalSize\"));\n\t\ttableHash.setCellValueFactory(new PropertyValueFactory<>(\"hash\"));\n\t}\n\n\tprivate void start()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(() -> fileClient.getUploads().collectMap(FileProgress::hash)\n\t\t\t\t\t\t.doOnSuccess(incomingProgresses -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert incomingProgresses != null;\n\t\t\t\t\t\t\tvar it = uploadTableView.getItems().iterator();\n\t\t\t\t\t\t\twhile (it.hasNext())\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvar currentProgress = it.next();\n\t\t\t\t\t\t\t\tvar incomingProgress = incomingProgresses.get(currentProgress.getHash());\n\t\t\t\t\t\t\t\tif (incomingProgress != null)\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tincomingProgresses.remove(incomingProgress.hash());\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tit.remove();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tincomingProgresses.forEach((_, fileProgress) -> uploadTableView.getItems().add(new FileProgressDisplay(fileProgress.id(), fileProgress.name(), TRANSFERRING, 0.0, fileProgress.totalSize(), fileProgress.hash())));\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe(),\n\t\t\t\t0,\n\t\t\t\tUPDATE_IN_SECONDS);\n\t}\n\n\tpublic void stop()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t}\n\n\tpublic void resume()\n\t{\n\t\tif (wasRunning)\n\t\t{\n\t\t\tstart();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void activate()\n\t{\n\t\tstart();\n\t\twasRunning = true;\n\t}\n\n\t@Override\n\tpublic void deactivate()\n\t{\n\t\tstop();\n\t\twasRunning = false;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/TimeCell.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport javafx.scene.control.TableCell;\n\nimport java.time.Instant;\n\nimport static io.xeres.ui.support.util.DateUtils.TIME_PRECISE_FORMAT;\n\nclass TimeCell extends TableCell<TrendResult, Instant>\n{\n\t@Override\n\tprotected void updateItem(Instant item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tsetText(null);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetText(TIME_PRECISE_FORMAT.format(item));\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/file/TrendResult.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.file;\n\nimport java.time.Instant;\n\nrecord TrendResult(String keywords, String senderName, Instant when)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/DateCell.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.ui.model.forum.ForumMessage;\nimport javafx.scene.control.TreeTableCell;\n\nimport java.time.Instant;\n\nimport static io.xeres.ui.support.util.DateUtils.DATE_TIME_FORMAT;\n\nclass DateCell extends TreeTableCell<ForumMessage, Instant>\n{\n\tpublic DateCell()\n\t{\n\t\tsuper();\n\t}\n\n\t@Override\n\tprotected void updateItem(Instant item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tsetText(null);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetText(DATE_TIME_FORMAT.format(item));\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/ForumCell.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.model.forum.ForumGroup;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TreeTableCell;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\npublic class ForumCell extends TreeTableCell<ForumGroup, ForumGroup>\n{\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tpublic ForumCell()\n\t{\n\t\tsuper();\n\t\tTooltipUtils.install(this,\n\t\t\t\t() -> {\n\t\t\t\t\tif (getItem().getId() == 0)\n\t\t\t\t\t{\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t\treturn MessageFormat.format(bundle.getString(\"gxs-group.tree.info\"),\n\t\t\t\t\t\t\tgetItem().getName(),\n\t\t\t\t\t\t\tgetItem().getGxsId(),\n\t\t\t\t\t\t\tgetItem().getVisibleMessageCount(),\n\t\t\t\t\t\t\tDateUtils.formatDateTime(getItem().getLastActivity(), bundle.getString(\"unknown-lc\"))\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tnull);\n\t}\n\n\t@Override\n\tprotected void updateItem(ForumGroup item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tsetText(null);\n\t\t\tclearStyle();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetText(item.getName());\n\t\t\tif (item.hasNewMessages())\n\t\t\t{\n\t\t\t\tsetStyle(\"-fx-font-weight: bold;\");\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tclearStyle();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void clearStyle()\n\t{\n\t\tsetStyle(\"\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/ForumCellAuthor.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.model.forum.ForumMessage;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.control.TreeTableCell;\nimport javafx.scene.image.ImageView;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\n\nclass ForumCellAuthor extends TreeTableCell<ForumMessage, ForumMessage>\n{\n\tprivate static final int AUTHOR_WIDTH = 24;\n\tprivate static final int AUTHOR_HEIGHT = 24;\n\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCache;\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tpublic ForumCellAuthor(GeneralClient generalClient, ImageCache imageCache)\n\t{\n\t\tsuper();\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCache = imageCache;\n\t\tTooltipUtils.install(this,\n\t\t\t\t() -> MessageFormat.format(bundle.getString(\"chat.room.user-info\"), super.getItem().getAuthorName(), super.getItem().getAuthorGxsId()),\n\t\t\t\t() -> new ImageView(((ImageView) super.getGraphic()).getImage()));\n\t}\n\n\t@Override\n\tprotected void updateItem(ForumMessage item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : getAuthorName(item));\n\t\tsetGraphic(empty ? null : updateAuthor((AsyncImageView) getGraphic(), item));\n\t}\n\n\tprivate static String getAuthorName(ForumMessage item)\n\t{\n\t\treturn item.getAuthorName() != null ? item.getAuthorName() : item.getGxsId().toString();\n\t}\n\n\tprivate AsyncImageView updateAuthor(AsyncImageView asyncImageView, ForumMessage message)\n\t{\n\t\tif (asyncImageView == null)\n\t\t{\n\t\t\tasyncImageView = new AsyncImageView(\n\t\t\t\t\turl -> generalClient.getImage(url).block(),\n\t\t\t\t\timageCache);\n\t\t\tasyncImageView.setFitWidth(AUTHOR_WIDTH);\n\t\t\tasyncImageView.setFitHeight(AUTHOR_HEIGHT);\n\t\t}\n\n\t\tasyncImageView.setUrl(getIdentityImageUrl(message));\n\n\t\treturn asyncImageView;\n\t}\n\n\tpublic static String getIdentityImageUrl(ForumMessage message)\n\t{\n\t\tif (message.getAuthorGxsId() != null)\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/image?gxsId=\" + message.getAuthorGxsId() + \"&find=true\";\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/ForumEditorWindowController.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.common.rest.forum.ForumPostRequest;\nimport io.xeres.ui.client.ForumClient;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.EditorView;\nimport io.xeres.ui.model.forum.ForumMessage;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.ProgressBar;\nimport javafx.scene.control.TextField;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\nimport static org.apache.commons.lang3.StringUtils.isBlank;\n\n@Component\n@FxmlView(value = \"/view/forum/forum_editor_view.fxml\")\npublic class ForumEditorWindowController implements WindowController\n{\n\t@FXML\n\tprivate TextField forumName;\n\n\t@FXML\n\tprivate TextField title;\n\n\t@FXML\n\tprivate EditorView editorView;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\t@FXML\n\tprivate Button send;\n\n\tprivate ForumPostRequest forumPostRequest;\n\n\tprivate final ForumClient forumClient;\n\tprivate final LocationClient locationClient;\n\tprivate final MarkdownService markdownService;\n\tprivate final ResourceBundle bundle;\n\n\tpublic ForumEditorWindowController(ForumClient forumClient, LocationClient locationClient, MarkdownService markdownService, ResourceBundle bundle)\n\t{\n\t\tthis.forumClient = forumClient;\n\t\tthis.locationClient = locationClient;\n\t\tthis.markdownService = markdownService;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tPlatform.runLater(() -> title.requestFocus());\n\n\t\teditorView.lengthProperty.addListener((_, _, newValue) -> checkSendable((Integer) newValue));\n\t\teditorView.setInputContextMenu(locationClient);\n\t\teditorView.setMarkdownService(markdownService);\n\t\ttitle.setOnKeyTyped(_ -> checkSendable(editorView.lengthProperty.getValue()));\n\n\t\tsend.setOnAction(_ -> postMessage());\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(title);\n\t\tif (userData == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing PostRequest\");\n\t\t}\n\n\t\tforumPostRequest = (ForumPostRequest) userData;\n\n\t\tforumClient.getForumGroupById(forumPostRequest.forumId())\n\t\t\t\t.doOnSuccess(forumGroup -> Platform.runLater(() -> {\n\t\t\t\t\tassert forumGroup != null;\n\t\t\t\t\tforumName.setText(forumGroup.getName());\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tif (forumPostRequest.messageId() != 0L)\n\t\t{\n\t\t\t// We're editing our message\n\t\t\tforumClient.getForumMessage(forumPostRequest.messageId())\n\t\t\t\t\t.doOnSuccess(forumMessage -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert forumMessage != null;\n\t\t\t\t\t\ttitle.setText(forumMessage.getName());\n\t\t\t\t\t\teditorView.setText(forumMessage.getContent());\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t\tsend.setText(bundle.getString(\"update\"));\n\t\t}\n\t\telse if (forumPostRequest.replyToId() != 0L)\n\t\t{\n\t\t\t// We're writing a new message and replying\n\t\t\ttitle.setDisable(true);\n\t\t\tforumClient.getForumMessage(forumPostRequest.replyToId())\n\t\t\t\t\t.doOnSuccess(forumMessage -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert forumMessage != null;\n\t\t\t\t\t\taddReply(forumMessage);\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t}\n\n\t\t// Prevent the message from being discarded by mistake\n\t\tUiUtils.getWindow(send).setOnCloseRequest(event -> {\n\t\t\tif (editorView.isModified())\n\t\t\t{\n\t\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"forum.editor.cancel\"), () -> UiUtils.getWindow(send).hide());\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void checkSendable(int editorLength)\n\t{\n\t\tsend.setDisable(isBlank(title.getText()) || editorLength == 0);\n\t}\n\n\tprivate void addReply(ForumMessage forumMessage)\n\t{\n\t\ttitle.setText((forumMessage.getParentId() == 0L ? \"Re: \" : \"\") + forumMessage.getName());\n\t\teditorView.setReply(forumMessage.getContent());\n\t}\n\n\tprivate void setWaiting(boolean waiting)\n\t{\n\t\ttitle.setDisable(waiting);\n\t\teditorView.setDisable(waiting);\n\t\tsend.setDisable(waiting);\n\t\tUiUtils.setPresent(progressBar, waiting);\n\t}\n\n\tprivate void postMessage()\n\t{\n\t\tsetWaiting(true);\n\t\tforumClient.createForumMessage(forumPostRequest.forumId(), title.getText(), editorView.getText(), forumPostRequest.replyToId(), forumPostRequest.messageId())\n\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(forumName)))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t.subscribe();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/ForumGroupWindowController.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.ui.client.ForumClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.ProgressBar;\nimport javafx.scene.control.TextField;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.lang3.Strings;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/forum/forum_group_view.fxml\")\npublic class ForumGroupWindowController implements WindowController\n{\n\t@FXML\n\tprivate Button createOrUpdateButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\t@FXML\n\tprivate TextField forumName;\n\n\t@FXML\n\tprivate TextField forumDescription;\n\n\t@FXML\n\tprivate ProgressBar progressBar;\n\n\tprivate final ForumClient forumClient;\n\tprivate final ResourceBundle bundle;\n\n\tprivate long forumId;\n\n\tprivate String initialName;\n\tprivate String initialDescription;\n\n\tpublic ForumGroupWindowController(ForumClient forumClient, ResourceBundle bundle)\n\t{\n\t\tthis.forumClient = forumClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tforumName.textProperty().addListener(_ -> checkCreatable());\n\t\tforumDescription.textProperty().addListener(_ -> checkCreatable());\n\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(forumName);\n\t\tif (userData != null)\n\t\t{\n\t\t\tforumId = (long) userData;\n\t\t}\n\n\t\tif (forumId != 0L)\n\t\t{\n\t\t\tforumClient.getForumGroupById(forumId)\n\t\t\t\t\t.doOnSuccess(forumGroup -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert forumGroup != null;\n\t\t\t\t\t\tforumName.setText(forumGroup.getName());\n\t\t\t\t\t\tforumDescription.setText(forumGroup.getDescription());\n\t\t\t\t\t\tinitialName = forumName.getText();\n\t\t\t\t\t\tinitialDescription = forumDescription.getText();\n\t\t\t\t\t\tcreateOrUpdateButton.setDisable(true);\n\t\t\t\t\t}))\n\t\t\t\t\t.subscribe();\n\t\t\tcreateOrUpdateButton.setText(bundle.getString(\"update\"));\n\t\t\tcreateOrUpdateButton.setOnAction(_ -> {\n\t\t\t\tsetWaiting(true);\n\t\t\t\tforumClient.updateForumGroup(forumId,\n\t\t\t\t\t\t\t\tforumName.getText(),\n\t\t\t\t\t\t\t\tforumDescription.getText())\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(forumName)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t\t\t.subscribe();\n\t\t\t});\n\t\t}\n\t\telse\n\t\t{\n\t\t\tcreateOrUpdateButton.setOnAction(_ -> {\n\t\t\t\tsetWaiting(true);\n\t\t\t\tforumClient.createForumGroup(forumName.getText(),\n\t\t\t\t\t\t\t\tforumDescription.getText())\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(forumName)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.doFinally(_ -> setWaiting(false))\n\t\t\t\t\t\t.subscribe();\n\t\t\t});\n\t\t}\n\t}\n\n\tprivate void setWaiting(boolean waiting)\n\t{\n\t\tforumName.setDisable(waiting);\n\t\tforumDescription.setDisable(waiting);\n\t\tcreateOrUpdateButton.setDisable(waiting);\n\t\tcancelButton.setDisable(waiting);\n\t\tUiUtils.setPresent(progressBar, waiting);\n\t}\n\n\tprivate void checkCreatable()\n\t{\n\t\tcreateOrUpdateButton.setDisable(forumId == 0L && forumName.getText().isBlank() ||\n\t\t\t\t(forumId == 0L && forumDescription.getText().isBlank()) ||\n\t\t\t\t(\n\t\t\t\t\t\tStrings.CS.equals(initialName, forumName.getText()) &&\n\t\t\t\t\t\t\t\tStrings.CS.equals(initialDescription, forumDescription.getText())\n\t\t\t\t)\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/ForumMessageCell.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.ui.model.forum.ForumMessage;\nimport javafx.scene.control.TreeTableRow;\n\npublic class ForumMessageCell extends TreeTableRow<ForumMessage>\n{\n\tpublic ForumMessageCell()\n\t{\n\t\tsuper();\n\t}\n\n\t@Override\n\tprotected void updateItem(ForumMessage item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tif (empty)\n\t\t{\n\t\t\tclearStyle();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (item.isRead())\n\t\t\t{\n\t\t\t\tclearStyle();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsetStyle(\"-fx-font-weight: bold\");\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void clearStyle()\n\t{\n\t\tsetStyle(\"\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.common.rest.forum.ForumPostRequest;\nimport io.xeres.common.rest.notification.forum.AddOrUpdateForumGroups;\nimport io.xeres.common.rest.notification.forum.AddOrUpdateForumMessages;\nimport io.xeres.common.rest.notification.forum.SetForumGroupMessagesReadState;\nimport io.xeres.common.rest.notification.forum.SetForumMessageReadState;\nimport io.xeres.ui.client.ForumClient;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.client.IdentityClient;\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.controller.common.GxsGroupTreeTableAction;\nimport io.xeres.ui.controller.common.GxsGroupTreeTableView;\nimport io.xeres.ui.custom.ProgressPane;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.event.UnreadEvent;\nimport io.xeres.ui.model.forum.ForumGroup;\nimport io.xeres.ui.model.forum.ForumMapper;\nimport io.xeres.ui.model.forum.ForumMessage;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.loader.OnDemandLoader;\nimport io.xeres.ui.support.loader.OnDemandLoaderAction;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.markdown.MarkdownService.Rendering;\nimport io.xeres.ui.support.unread.UnreadService;\nimport io.xeres.ui.support.uri.ForumUri;\nimport io.xeres.ui.support.uri.IdentityUri;\nimport io.xeres.ui.support.uri.UriService;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.TextFlowDragSelection;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.value.ChangeListener;\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.*;\nimport javafx.scene.control.cell.TreeItemPropertyValueFactory;\nimport javafx.scene.layout.GridPane;\nimport javafx.scene.text.TextFlow;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.*;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.ContextClosedEvent;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\nimport reactor.core.scheduler.Schedulers;\n\nimport java.time.Instant;\nimport java.util.*;\n\nimport static io.xeres.common.dto.identity.IdentityConstants.OWN_IDENTITY_ID;\nimport static io.xeres.ui.support.preference.PreferenceUtils.FORUMS;\nimport static io.xeres.ui.support.util.DateUtils.DATE_TIME_PRECISE_FORMAT;\nimport static javafx.scene.control.Alert.AlertType.WARNING;\nimport static javafx.scene.control.TreeTableColumn.SortType.DESCENDING;\n\n@Component\n@FxmlView(value = \"/view/forum/forum_view.fxml\")\npublic class ForumViewController implements Controller, GxsGroupTreeTableAction<ForumGroup>, OnDemandLoaderAction<ForumGroup>\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ForumViewController.class);\n\n\tprivate static final String EDIT_FORUM_MESSAGE_MENU_ID = \"editForumMessage\";\n\tprivate static final String COPY_LINK_MENU_ID = \"copyLink\";\n\n\t@FXML\n\tprivate GxsGroupTreeTableView<ForumGroup> forumTree;\n\n\t@FXML\n\tprivate SplitPane splitPaneVertical;\n\n\t@FXML\n\tprivate SplitPane splitPaneHorizontal;\n\n\t@FXML\n\tprivate TreeTableView<ForumMessage> forumMessagesTreeTableView;\n\n\t@FXML\n\tprivate TreeTableColumn<ForumMessage, String> treeTableSubject;\n\n\t@FXML\n\tprivate TreeTableColumn<ForumMessage, ForumMessage> treeTableAuthor;\n\n\t@FXML\n\tprivate TreeTableColumn<ForumMessage, Instant> treeTableDate;\n\n\t@FXML\n\tprivate ProgressPane forumMessagesProgress;\n\n\t@FXML\n\tprivate ScrollPane messagePane;\n\n\t@FXML\n\tprivate TextFlow messageContent;\n\n\t@FXML\n\tpublic Button createForum;\n\n\t@FXML\n\tprivate Button newThread;\n\n\t@FXML\n\tprivate GridPane messageHeader;\n\n\t@FXML\n\tprivate Label messageAuthor;\n\n\t@FXML\n\tprivate Label messageDate;\n\n\t@FXML\n\tprivate Label messageSubject;\n\n\t@FXML\n\tprivate ChoiceBox<MessageVersion> versionChoiceBox;\n\n\tprivate final ObservableList<ForumMessage> messages = FXCollections.observableArrayList();\n\n\tprivate OnDemandLoader<ForumGroup, ForumMessage> onDemandLoader;\n\n\tprivate final ObservableList<MessageVersion> versions = FXCollections.observableArrayList();\n\n\tprivate int versionsFetcherRun;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate final ForumClient forumClient;\n\tprivate final NotificationClient notificationClient;\n\tprivate final WindowManager windowManager;\n\tprivate final MarkdownService markdownService;\n\tprivate final UriService uriService;\n\tprivate final GeneralClient generalClient;\n\tprivate final ImageCache imageCacheService;\n\tprivate final UnreadService unreadService;\n\tprivate final IdentityClient identityClient;\n\n\tprivate ForumMessage selectedForumMessage;\n\n\tprivate Disposable notificationDisposable;\n\n\tprivate TreeItem<ForumMessage> forumMessagesRoot;\n\n\tprivate MsgId toSelectMsgId;\n\tprivate UrlToOpen urlToOpen;\n\n\tprivate GxsId ownIdentityGxsId;\n\n\tprivate final ChangeListener<MessageVersion> changeVersionListener = (_, _, messageVersion) -> {\n\t\tif (messageVersion != null)\n\t\t{\n\t\t\tchangeSelectedForumMessageVersion(messageVersion.id());\n\t\t}\n\t};\n\n\tpublic ForumViewController(ForumClient forumClient, ResourceBundle bundle, NotificationClient notificationClient, WindowManager windowManager, MarkdownService markdownService, UriService uriService, GeneralClient generalClient, ImageCache imageCacheService, UnreadService unreadService, IdentityClient identityClient)\n\t{\n\t\tthis.forumClient = forumClient;\n\t\tthis.bundle = bundle;\n\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.windowManager = windowManager;\n\t\tthis.markdownService = markdownService;\n\t\tthis.uriService = uriService;\n\t\tthis.generalClient = generalClient;\n\t\tthis.imageCacheService = imageCacheService;\n\t\tthis.unreadService = unreadService;\n\t\tthis.identityClient = identityClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tlog.debug(\"Trying to get forums list...\");\n\n\t\tforumTree.initialize(FORUMS,\n\t\t\t\tforumClient,\n\t\t\t\tForumGroup::new,\n\t\t\t\tForumCell::new,\n\t\t\t\tthis);\n\n\t\tforumTree.unreadProperty().addListener((_, _, newValue) -> unreadService.sendUnreadEvent(UnreadEvent.Element.FORUM, newValue));\n\n\t\tforumMessagesTreeTableView.setRowFactory(_ -> new ForumMessageCell());\n\t\tcreateForumMessageTableViewContextMenu();\n\t\ttreeTableSubject.setCellValueFactory(new TreeItemPropertyValueFactory<>(\"name\"));\n\t\ttreeTableAuthor.setCellFactory(_ -> new ForumCellAuthor(generalClient, imageCacheService));\n\t\ttreeTableAuthor.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getValue()));\n\n\t\ttreeTableDate.setCellFactory(_ -> new DateCell());\n\t\ttreeTableDate.setCellValueFactory(new TreeItemPropertyValueFactory<>(\"published\"));\n\n\t\tforumMessagesRoot = new TreeItem<>(new ForumMessage());\n\t\tforumMessagesTreeTableView.setRoot(forumMessagesRoot);\n\t\tforumMessagesTreeTableView.setShowRoot(false);\n\n\t\tforumMessagesTreeTableView.getSortOrder().add(treeTableDate);\n\t\ttreeTableDate.setSortType(DESCENDING);\n\t\ttreeTableDate.setSortable(true);\n\n\t\tforumMessagesTreeTableView.getSelectionModel().selectedItemProperty()\n\t\t\t\t.addListener((_, _, newValue) -> changeSelectedForumMessage(newValue != null ? newValue.getValue() : null));\n\n\t\tidentityClient.findById(OWN_IDENTITY_ID)\n\t\t\t\t.doOnSuccess(identity -> Platform.runLater(() -> {\n\t\t\t\t\tassert identity != null;\n\t\t\t\t\townIdentityGxsId = identity.getGxsId();\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tversionChoiceBox.setItems(versions);\n\n\t\tonDemandLoader = new OnDemandLoader<>(forumMessagesTreeTableView, messages, forumClient, this);\n\n\t\tcreateForum.setOnAction(_ -> windowManager.openForumCreation(0L));\n\n\t\tnewThread.setOnAction(_ -> newForumPost(false));\n\n\t\tsetupForumNotifications();\n\n\t\tTextFlowDragSelection.enableSelection(messageContent, messagePane);\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvent(OpenUriEvent event)\n\t{\n\t\tif (event.uri() instanceof ForumUri forumUri)\n\t\t{\n\t\t\tif (!forumTree.openUrl(forumUri.gxsId(), forumUri.msgId()))\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(WARNING, bundle.getString(\"forum.view.group.not-found\"));\n\t\t\t}\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onOpenUrl(GxsId gxsId, MsgId msgId)\n\t{\n\t\tif (gxsId.equals(forumTree.getSelectedGroupGxsId()))\n\t\t{\n\t\t\tselectMessage(msgId);\n\t\t}\n\t\telse\n\t\t{\n\t\t\turlToOpen = new UrlToOpen(gxsId, msgId);\n\t\t}\n\t}\n\n\tprivate void setMessageToSelect(MsgId msgId)\n\t{\n\t\tif (msgId != null)\n\t\t{\n\t\t\ttoSelectMsgId = msgId;\n\t\t}\n\t}\n\n\tprivate void selectMessageIfNeeded()\n\t{\n\t\tif (toSelectMsgId != null)\n\t\t{\n\t\t\tforumMessagesRoot.getChildren().stream()\n\t\t\t\t\t.filter(forumMessageTreeItem -> forumMessageTreeItem.getValue().getMsgId().equals(toSelectMsgId))\n\t\t\t\t\t.findFirst()\n\t\t\t\t\t.ifPresent(forumMessageTreeItem -> Platform.runLater(() -> forumMessagesTreeTableView.getSelectionModel().select(forumMessageTreeItem)));\n\t\t}\n\t}\n\n\tprivate void selectMessage(MsgId msgId)\n\t{\n\t\tforumMessagesRoot.getChildren().stream()\n\t\t\t\t.filter(forumMessageTreeItem -> forumMessageTreeItem.getValue().getMsgId().equals(msgId))\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresentOrElse(forumMessageTreeItem -> Platform.runLater(() -> forumMessagesTreeTableView.getSelectionModel().select(forumMessageTreeItem)),\n\t\t\t\t\t\t() -> UiUtils.showAlert(WARNING, bundle.getString(\"forum.view.message.not-found\")));\n\t}\n\n\tprivate void createForumMessageTableViewContextMenu()\n\t{\n\t\tvar replyItem = new MenuItem(bundle.getString(\"forum.view.reply\"));\n\t\treplyItem.setGraphic(new FontIcon(MaterialDesignR.REPLY));\n\t\treplyItem.setOnAction(_ -> newForumPost(true));\n\n\t\tvar markUnreadItem = new MenuItem(bundle.getString(\"mark-unread\"));\n\t\tmarkUnreadItem.setGraphic(new FontIcon(MaterialDesignE.EMAIL_MARK_AS_UNREAD));\n\t\tmarkUnreadItem.setOnAction(_ -> markAsUnread());\n\n\t\tvar editItem = new MenuItem(bundle.getString(\"edit\"));\n\t\teditItem.setId(EDIT_FORUM_MESSAGE_MENU_ID);\n\t\teditItem.setGraphic(new FontIcon(MaterialDesignS.SQUARE_EDIT_OUTLINE));\n\t\teditItem.setOnAction(_ -> editForumPost());\n\n\t\tvar copyLinkItem = new MenuItem(bundle.getString(\"copy-link\"));\n\t\tcopyLinkItem.setId(COPY_LINK_MENU_ID);\n\t\tcopyLinkItem.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tcopyLinkItem.setOnAction(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var forumMessage = ((TreeItem<ForumMessage>) event.getSource()).getValue();\n\t\t\tvar forumUri = new ForumUri(forumMessage.getName(), forumMessage.getGxsId(), forumMessage.getMsgId());\n\t\t\tClipboardUtils.copyTextToClipboard(forumUri.toUriString());\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<TreeItem<ForumMessage>>(replyItem, markUnreadItem, editItem, new SeparatorMenuItem(), copyLinkItem);\n\t\txContextMenu.setOnShowing((contextMenu, treeItem) -> {\n\t\t\tif (treeItem == null)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> EDIT_FORUM_MESSAGE_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setVisible(treeItem.getValue().getAuthorGxsId() != null && treeItem.getValue().getAuthorGxsId().equals(ownIdentityGxsId)));\n\t\t\treturn true;\n\t\t});\n\t\txContextMenu.addToNode(forumMessagesTreeTableView);\n\t}\n\n\tprivate void newForumPost(boolean replyTo)\n\t{\n\t\tvar replyToId = 0L;\n\n\t\tif (selectedForumMessage != null)\n\t\t{\n\t\t\treplyToId = replyTo ? selectedForumMessage.getId() : 0L;\n\t\t}\n\n\t\tvar postRequest = new ForumPostRequest(forumTree.getSelectedGroupId(), replyToId, 0L);\n\t\twindowManager.openForumEditor(postRequest);\n\t}\n\n\tprivate void markAsUnread()\n\t{\n\t\tif (selectedForumMessage == null) // Should not happen\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tforumClient.setForumMessageReadState(selectedForumMessage.getId(), false)\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void editForumPost()\n\t{\n\t\tif (selectedForumMessage != null)\n\t\t{\n\t\t\tvar postRequest = new ForumPostRequest(forumTree.getSelectedGroupId(), 0L, selectedForumMessage.getId());\n\t\t\twindowManager.openForumEditor(postRequest);\n\t\t}\n\t}\n\n\tprivate void setupForumNotifications()\n\t{\n\t\tnotificationDisposable = notificationClient.getForumNotifications()\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.doOnNext(sse -> Platform.runLater(() -> {\n\t\t\t\t\tswitch (sse.data())\n\t\t\t\t\t{\n\t\t\t\t\t\tcase AddOrUpdateForumGroups action -> forumTree.addGroups(action.forumGroups().stream()\n\t\t\t\t\t\t\t\t.map(ForumMapper::fromDTO)\n\t\t\t\t\t\t\t\t.toList());\n\t\t\t\t\t\tcase AddOrUpdateForumMessages action -> addForumMessages(action.forumMessages().stream()\n\t\t\t\t\t\t\t\t.map(ForumMapper::fromDTO)\n\t\t\t\t\t\t\t\t.toList());\n\t\t\t\t\t\tcase SetForumMessageReadState action -> setMessageReadState(action.groupId(), action.messageId(), action.read());\n\t\t\t\t\t\tcase SetForumGroupMessagesReadState action -> setGroupMessagesReadState(action.groupId(), action.read());\n\t\t\t\t\t\tcase null -> throw new IllegalArgumentException(\"Forum notifications have not been set\");\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void forumMessagesState(boolean loading)\n\t{\n\t\tPlatform.runLater(() -> forumMessagesProgress.showProgress(loading));\n\t}\n\n\t// XXX: implement threaded support for the 2 following methods.\n\t// if the message has a parentId, find it in the list then add the message to it.\n\t// could be slow if the list is big so find tricks to speed it up\n\tprivate List<TreeItem<ForumMessage>> toTreeItemForumMessages(List<ForumMessage> forumMessages)\n\t{\n\t\treturn forumMessages.stream()\n\t\t\t\t.map(TreeItem::new)\n\t\t\t\t.toList();\n\t}\n\n\tprivate void changeSelectedForumMessage(ForumMessage forumMessage)\n\t{\n\t\tselectedForumMessage = forumMessage;\n\n\t\tif (forumMessage != null)\n\t\t{\n\t\t\tforumClient.getForumMessage(forumMessage.getId())\n\t\t\t\t\t.doOnSuccess(message -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert message != null;\n\t\t\t\t\t\tsetCommonMessageAttributes(message);\n\t\t\t\t\t\tmessageAuthor.setText(message.getAuthorName());\n\t\t\t\t\t\tcreateAuthorContextMenu(message.getAuthorName(), message.getAuthorGxsId());\n\t\t\t\t\t\tsetupMessageVersionSelector(message);\n\t\t\t\t\t\tUiUtils.setPresent(messageHeader);\n\t\t\t\t\t\tif (!message.isRead())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tforumClient.setForumMessageReadState(message.getId(), true)\n\t\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t\t}\n\t\t\t\t\t}))\n\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t.subscribe();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tclearMessage();\n\t\t}\n\t}\n\n\tprivate void changeSelectedForumMessageVersion(long id)\n\t{\n\t\tif (selectedForumMessage != null)\n\t\t{\n\t\t\tforumClient.getForumMessage(id)\n\t\t\t\t\t.doOnSuccess(message -> Platform.runLater(() -> {\n\t\t\t\t\t\tassert message != null;\n\t\t\t\t\t\tsetCommonMessageAttributes(message);\n\t\t\t\t\t}))\n\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tprivate void setCommonMessageAttributes(ForumMessage forumMessage)\n\t{\n\t\tmessageContent.getChildren().clear();\n\t\tmessagePane.setVvalue(messagePane.getVmin()); // Reset scroll position\n\t\taddMessageContent(forumMessage.getContent());\n\t\tmessageDate.setText(DATE_TIME_PRECISE_FORMAT.format(forumMessage.getPublished()));\n\t\tmessageSubject.setText(forumMessage.getName());\n\t}\n\n\tprivate void setupMessageVersionSelector(ForumMessage forumMessage)\n\t{\n\t\tversionChoiceBox.getSelectionModel().selectedItemProperty().removeListener(changeVersionListener); // Prevent listener from kicking in while we fill and select entries\n\n\t\tversionChoiceBox.setVisible(forumMessage.getOriginalId() != 0L);\n\t\tversions.clear();\n\t\tversions.addFirst(new MessageVersion(null, forumMessage.getId()));\n\t\tversionChoiceBox.getSelectionModel().selectFirst();\n\n\t\tversionChoiceBox.getSelectionModel().selectedItemProperty().addListener(changeVersionListener);\n\n\t\tif (forumMessage.getOriginalId() != 0L)\n\t\t{\n\t\t\tfetchVersions(forumMessage.getOriginalId(), ++versionsFetcherRun, 0);\n\t\t}\n\t}\n\n\tprivate void fetchVersions(long id, int run, int recursion)\n\t{\n\t\tif (versionsFetcherRun != run)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tforumClient.getForumMessage(id)\n\t\t\t\t.publishOn(Schedulers.boundedElastic())\n\t\t\t\t.doOnSuccess(message -> Platform.runLater(() -> {\n\t\t\t\t\tassert message != null;\n\n\t\t\t\t\tversions.add(new MessageVersion(message.getPublished(), message.getId()));\n\n\t\t\t\t\tif (message.getOriginalId() != 0L && recursion < 16)\n\t\t\t\t\t{\n\t\t\t\t\t\tfetchVersions(message.getOriginalId(), run, recursion + 1);\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void clearMessage()\n\t{\n\t\tUiUtils.setAbsent(messageHeader);\n\t\tmessageAuthor.setText(null);\n\t\tmessageAuthor.setContextMenu(null);\n\t\tmessageDate.setText(null);\n\t\tmessageSubject.setText(null);\n\t\tmessageContent.getChildren().clear();\n\t}\n\n\tprivate void addForumMessages(List<ForumMessage> forumMessages)\n\t{\n\t\tSet<GxsId> forumsToUpdate = new HashSet<>();\n\n\t\tfor (ForumMessage forumMessage : forumMessages)\n\t\t{\n\t\t\tonDemandLoader.insertMessage(forumMessage);\n\t\t\tforumsToUpdate.add(forumMessage.getGxsId());\n\t\t}\n\t\tforumTree.refreshUnreadCount(forumsToUpdate);\n\t\trefreshMessageList();\n\t}\n\n\tprivate void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\t// Avoids flickering because of some current Flowless limitation\n\t\tif (selectedForumMessage != null && selectedForumMessage.getId() == messageId && !selectedForumMessage.isRead())\n\t\t{\n\t\t\tforumTree.setUnreadCount(groupId, read);\n\t\t\tselectedForumMessage.setRead(read);\n\t\t\tforumMessagesTreeTableView.refresh();\n\t\t}\n\t}\n\n\tprivate void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tonDemandLoader.setGroupMessagesReadState(groupId, read);\n\t\tforumTree.refreshUnreadCount(groupId);\n\t}\n\n\t@EventListener\n\tpublic void onApplicationEvent(ContextClosedEvent ignored)\n\t{\n\t\tif (notificationDisposable != null && !notificationDisposable.isDisposed())\n\t\t{\n\t\t\tnotificationDisposable.dispose();\n\t\t}\n\t}\n\n\tprivate void createAuthorContextMenu(String name, GxsId gxsId)\n\t{\n\t\tvar infoItem = new MenuItem(bundle.getString(\"chat.room.user-menu\"));\n\t\tinfoItem.setGraphic(new FontIcon(MaterialDesignA.ACCOUNT_BOX));\n\t\tinfoItem.setOnAction(_ -> uriService.openUri(new IdentityUri(name, gxsId, null)));\n\t\tmessageAuthor.setContextMenu(new ContextMenu(infoItem));\n\t}\n\n\t@Override\n\tpublic void onSubscribeToGroup(ForumGroup group)\n\t{\n\t}\n\n\t@Override\n\tpublic void onUnsubscribeFromGroup(ForumGroup group)\n\t{\n\t}\n\n\t@Override\n\tpublic void onCopyGroupLink(ForumGroup group)\n\t{\n\t\tvar forumUri = new ForumUri(group.getName(), group.getGxsId(), null);\n\t\tClipboardUtils.copyTextToClipboard(forumUri.toUriString());\n\t}\n\n\t@Override\n\tpublic void onSelectSubscribedGroup(ForumGroup group)\n\t{\n\t\tshowInfo(group);\n\t\tforumMessagesState(true);\n\t\tonDemandLoader.changeSelection(group);\n\t\tnewThread.setDisable(false);\n\t}\n\n\tprivate void saveSelection()\n\t{\n\t\tvar selectedItem = forumMessagesTreeTableView.getSelectionModel().getSelectedItem();\n\t\tif (selectedItem != null)\n\t\t{\n\t\t\tsetMessageToSelect(selectedItem.getValue().getMsgId());\n\t\t\tforumMessagesTreeTableView.getSelectionModel().clearSelection();\n\t\t}\n\t}\n\n\tprivate void refreshMessageList()\n\t{\n\t\tsaveSelection();\n\t\tforumMessagesRoot.getChildren().clear();\n\t\tforumMessagesRoot.getChildren().addAll(toTreeItemForumMessages(messages));\n\t\tforumMessagesTreeTableView.sort();\n\t\tnewThread.setDisable(false);\n\t\tselectMessageIfNeeded();\n\n\t\tforumMessagesState(false);\n\t}\n\n\t@Override\n\tpublic void onSelectUnsubscribedGroup(ForumGroup group)\n\t{\n\t\tonDemandLoader.changeSelection(group);\n\t\tnewThread.setDisable(true);\n\t\tshowInfo(group);\n\t}\n\n\t@Override\n\tpublic void onUnselectGroup()\n\t{\n\t\tonDemandLoader.changeSelection(null);\n\t\tnewThread.setDisable(true);\n\t\tshowInfo(null);\n\t}\n\n\t@Override\n\tpublic void onEditGroup(ForumGroup group)\n\t{\n\t\twindowManager.openForumCreation(group.getId());\n\t}\n\n\tprivate void showInfo(ForumGroup group)\n\t{\n\t\tselectedForumMessage = null;\n\n\t\tforumMessagesTreeTableView.getSelectionModel().clearSelection();\n\t\tforumMessagesRoot.getChildren().clear();\n\t\tclearMessage();\n\t\tif (group != null && group.isReal())\n\t\t{\n\t\t\taddMessageContent(\"\"\"\n\t\t\t\t\t## %s\n\t\t\t\t\t\n\t\t\t\t\t%s\n\t\t\t\t\t\n\t\t\t\t\t%s: %s\\\\\n\t\t\t\t\t%s: %s\n\t\t\t\t\t\"\"\".formatted(\n\t\t\t\t\tgroup.getName(),\n\t\t\t\t\tgroup.getDescription(),\n\t\t\t\t\tbundle.getString(\"posts-at-remote-nodes\"),\n\t\t\t\t\tgroup.getVisibleMessageCount(),\n\t\t\t\t\tbundle.getString(\"last-activity\"),\n\t\t\t\t\tDateUtils.formatDateTime(group.getLastActivity(), bundle.getString(\"unknown-lc\"))\n\t\t\t));\n\t\t}\n\t\tforumMessagesState(false);\n\t\ttoSelectMsgId = null;\n\t}\n\n\tprivate void addMessageContent(String input)\n\t{\n\t\tmessageContent.getChildren().addAll(markdownService.parse(input, EnumSet.noneOf(Rendering.class)).stream()\n\t\t\t\t.map(Content::getNode).toList());\n\t}\n\n\t@Override\n\tpublic void onMessagesLoaded(ForumGroup group)\n\t{\n\t\trefreshMessageList();\n\t\tif (urlToOpen != null)\n\t\t{\n\t\t\tif (group.getGxsId().equals(urlToOpen.gxsId()))\n\t\t\t{\n\t\t\t\tselectMessage(urlToOpen.msgId());\n\t\t\t\turlToOpen = null;\n\t\t\t}\n\t\t}\n\t}\n\n\trecord UrlToOpen(GxsId gxsId, MsgId msgId)\n\t{\n\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/forum/MessageVersion.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.forum;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.support.util.DateUtils;\n\nimport java.time.Instant;\n\nrecord MessageVersion(Instant instant, Long id)\n{\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn instant != null ? DateUtils.DATE_TIME_PRECISE_FORMAT.format(instant) : I18nUtils.getBundle().getString(\"latest\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/help/HelpWindowController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.help;\n\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.EditorView;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.uri.ExternalUri;\nimport io.xeres.ui.support.uri.UriService;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Alert;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.ListView;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.core.io.Resource;\nimport org.springframework.core.io.support.ResourcePatternResolver;\nimport org.springframework.stereotype.Component;\n\nimport java.io.IOException;\nimport java.util.Arrays;\nimport java.util.Locale;\nimport java.util.Set;\nimport java.util.stream.Stream;\n\n@Component\n@FxmlView(value = \"/view/help/help.fxml\")\npublic class HelpWindowController implements WindowController\n{\n\tpublic static final String INDEX_MD = \"00.Index.md\";\n\n\tprivate static final Set<String> SUPPORTED_LOCALES = Set.of(\"en\", \"es\", \"fr\", \"ru\", \"zh\");\n\n\t@FXML\n\tprivate Button back;\n\n\t@FXML\n\tprivate Button forward;\n\n\t@FXML\n\tprivate Button home;\n\n\t@FXML\n\tprivate ListView<Resource> indexList;\n\n\t@FXML\n\tprivate EditorView editorView;\n\n\tprivate String language;\n\n\tprivate final MarkdownService markdownService;\n\tprivate final ResourcePatternResolver resourcePatternResolver;\n\tprivate final UriService uriService;\n\tprivate Navigator navigator;\n\n\tpublic HelpWindowController(MarkdownService markdownService, ResourcePatternResolver resourcePatternResolver, UriService uriService)\n\t{\n\t\tthis.markdownService = markdownService;\n\t\tthis.resourcePatternResolver = resourcePatternResolver;\n\t\tthis.uriService = uriService;\n\t}\n\n\t@Override\n\tpublic void initialize() throws IOException\n\t{\n\t\tlanguage = Stream.of(Locale.getDefault().getLanguage())\n\t\t\t\t.filter(SUPPORTED_LOCALES::contains)\n\t\t\t\t.findFirst()\n\t\t\t\t.orElse(\"en\");\n\n\t\tvar resources = Arrays.stream(resourcePatternResolver.getResources(\"classpath:help/\" + language + \"/*.md\"))\n\t\t\t\t.filter(resource -> !StringUtils.defaultString(resource.getFilename()).equals(INDEX_MD))\n\t\t\t\t.toList();\n\t\tindexList.getItems().addAll(resources);\n\t\tindexList.setCellFactory(_ -> new IndexCell());\n\t\tindexList.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> {\n\t\t\tif (newValue != null)\n\t\t\t{\n\t\t\t\tnavigator.navigate(new ExternalUri(newValue.getFilename()));\n\t\t\t}\n\t\t});\n\n\t\tnavigator = new Navigator(uri -> {\n\t\t\tif (uri instanceof ExternalUri externalUri)\n\t\t\t{\n\t\t\t\tvar plain = uri.toUriString();\n\n\t\t\t\tif (navigator.isNavigable(uri))\n\t\t\t\t{\n\t\t\t\t\tvar resource = HelpWindowController.class.getResourceAsStream(\"/help/\" + language + \"/\" + plain);\n\t\t\t\t\tif (resource != null)\n\t\t\t\t\t{\n\t\t\t\t\t\teditorView.setMarkdown(resource);\n\t\t\t\t\t\tselectListViewItemIfNeeded(plain);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tUiUtils.showAlert(Alert.AlertType.ERROR, \"Couldn't find resource for link '\" + plain + \"'\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\turiService.openUri(externalUri);\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(Alert.AlertType.ERROR, \"Unhandled URI '\" + uri + \"'\");\n\t\t\t}\n\t\t});\n\n\t\teditorView.setMarkdownService(markdownService);\n\n\t\tback.disableProperty().bind(navigator.backProperty.not());\n\t\tforward.disableProperty().bind(navigator.forwardProperty.not());\n\n\t\thome.setOnAction(_ -> navigator.navigate(new ExternalUri(INDEX_MD)));\n\t\tback.setOnAction(_ -> navigator.navigateBackwards());\n\t\tforward.setOnAction(_ -> navigator.navigateForwards());\n\n\t\teditorView.setUriAction(navigator::navigate);\n\t\tnavigator.navigate(new ExternalUri(INDEX_MD));\n\t}\n\n\tprivate void selectListViewItemIfNeeded(String url)\n\t{\n\t\tif (url == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tindexList.getItems().stream()\n\t\t\t\t.filter(resource -> url.equals(resource.getFilename()))\n\t\t\t\t.findFirst()\n\t\t\t\t.ifPresentOrElse(resource -> indexList.getSelectionModel().select(resource), () -> indexList.getSelectionModel().clearSelection());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/help/IndexCell.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.help;\n\nimport javafx.scene.control.ListCell;\nimport org.springframework.core.io.Resource;\n\nclass IndexCell extends ListCell<Resource>\n{\n\t@Override\n\tprotected void updateItem(Resource resource, boolean empty)\n\t{\n\t\tsuper.updateItem(resource, empty);\n\t\tif (empty || resource == null)\n\t\t{\n\t\t\tsetText(null);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetText(prettify(resource.getFilename()));\n\t\t}\n\t}\n\n\tprivate static String prettify(String fileName)\n\t{\n\t\tif (fileName == null)\n\t\t{\n\t\t\treturn \"???\";\n\t\t}\n\t\treturn fileName.substring(3, fileName.length() - 3);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/help/Navigator.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.help;\n\nimport io.xeres.ui.support.uri.ExternalUri;\nimport io.xeres.ui.support.uri.Uri;\nimport javafx.beans.property.BooleanProperty;\nimport javafx.beans.property.SimpleBooleanProperty;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.function.Consumer;\n\n/**\n * This classe handles a navigation using a forward and backwards paradigm. A bit like a web\n * browser but without having to suffer frames navigation (which I believe is broken anyway).\n */\nclass Navigator\n{\n\tprivate final List<Uri> history = new ArrayList<>();\n\tprivate int historyIndex = -1;\n\n\tprivate final Consumer<Uri> action;\n\n\tfinal BooleanProperty backProperty = new SimpleBooleanProperty(false);\n\tfinal BooleanProperty forwardProperty = new SimpleBooleanProperty(false);\n\n\tpublic Navigator(Consumer<Uri> action)\n\t{\n\t\tthis.action = action;\n\t}\n\n\tpublic void navigateBackwards()\n\t{\n\t\tif (historyIndex == 0)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\taction.accept(history.get(--historyIndex));\n\t\tupdateProperties();\n\t}\n\n\tpublic void navigateForwards()\n\t{\n\t\tif (historyIndex == history.size() - 1)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\taction.accept(history.get(++historyIndex));\n\t\tupdateProperties();\n\t}\n\n\tpublic void navigate(Uri uri)\n\t{\n\t\tObjects.requireNonNull(uri, \"uri must not be null\");\n\t\tif (uri.equals(getCurrentUri()))\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tif (isNavigable(uri)) // We don't want to reopen web URLs when coming back, etc...\n\t\t{\n\t\t\taddToHistoryAndTrim(uri);\n\t\t}\n\t\taction.accept(uri);\n\t\tupdateProperties();\n\t}\n\n\tpublic Uri getCurrentUri()\n\t{\n\t\tif (history.isEmpty())\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn history.get(historyIndex);\n\t}\n\n\tpublic boolean isNavigable(Uri uri)\n\t{\n\t\tif (uri instanceof ExternalUri externalUri)\n\t\t{\n\t\t\treturn externalUri.toUriString().endsWith(\".md\");\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate void addToHistoryAndTrim(Uri uri)\n\t{\n\t\twhile (history.size() - 1 > historyIndex)\n\t\t{\n\t\t\thistory.removeLast();\n\t\t}\n\t\thistory.addLast(uri);\n\t\thistoryIndex = history.size() - 1;\n\t}\n\n\tprivate void updateProperties()\n\t{\n\t\tbackProperty.set(historyIndex > 0);\n\t\tforwardProperty.set(historyIndex < history.size() - 1);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/id/AddRsIdWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.id;\n\nimport io.xeres.common.geoip.Country;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.protocol.HostPort;\nimport io.xeres.common.protocol.i2p.I2pAddress;\nimport io.xeres.common.protocol.ip.IP;\nimport io.xeres.common.protocol.tor.OnionAddress;\nimport io.xeres.common.util.NoSuppressedRunnable;\nimport io.xeres.ui.client.GeoIpClient;\nimport io.xeres.ui.client.ProfileClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.model.connection.Connection;\nimport io.xeres.ui.support.util.TextInputControlUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.animation.PauseTransition;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.*;\nimport javafx.scene.image.ImageView;\nimport javafx.util.Duration;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Arrays;\nimport java.util.Comparator;\nimport java.util.Locale;\nimport java.util.ResourceBundle;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.regex.Pattern;\n\nimport static org.apache.commons.collections4.CollectionUtils.emptyIfNull;\n\n@Component\n@FxmlView(value = \"/view/id/rsid_add.fxml\")\npublic class AddRsIdWindowController implements WindowController\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(AddRsIdWindowController.class);\n\n\tprivate static final Pattern RSID_CLEANER = Pattern.compile(\"([\\r\\n\\t])\");\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\t@FXML\n\tprivate Button addButton;\n\n\t@FXML\n\tprivate TextArea rsIdTextArea;\n\n\t@FXML\n\tprivate TextField certName;\n\n\t@FXML\n\tprivate TextField certId;\n\n\t@FXML\n\tprivate TextField certFingerprint;\n\n\t@FXML\n\tprivate TextField certLocId;\n\n\t@FXML\n\tprivate ComboBox<AddressCountry> certIps;\n\n\t@FXML\n\tprivate ImageView imageFlag;\n\n\t@FXML\n\tprivate ChoiceBox<Trust> trust;\n\n\t@FXML\n\tprivate TitledPane titledPane;\n\n\t@FXML\n\tprivate Label status;\n\n\t@FXML\n\tprivate Button scanQrCode;\n\n\tprivate final ProfileClient profileClient;\n\tprivate final GeoIpClient geoIpClient;\n\tprivate final ResourceBundle bundle;\n\tprivate final WindowManager windowManager;\n\n\tpublic AddRsIdWindowController(ProfileClient profileClient, GeoIpClient geoIpClient, ResourceBundle bundle, WindowManager windowManager)\n\t{\n\t\tthis.profileClient = profileClient;\n\t\tthis.geoIpClient = geoIpClient;\n\t\tthis.bundle = bundle;\n\t\tthis.windowManager = windowManager;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tscanQrCode.setOnAction(_ -> windowManager.openCamera(this));\n\t\taddButton.setOnAction(_ -> addPeer());\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\n\t\tvar debouncer = new PauseTransition(Duration.millis(250.0));\n\t\trsIdTextArea.textProperty().addListener((_, _, newValue) -> {\n\t\t\tdebouncer.setOnFinished(_ -> checkRsId(newValue));\n\t\t\tdebouncer.playFromStart();\n\t\t});\n\t\tTextInputControlUtils.addEnhancedInputContextMenu(rsIdTextArea, null, null);\n\n\t\tcertIps.setCellFactory(_ -> new AddressCell());\n\t\tcertIps.setConverter(new AddressConverter());\n\n\t\tPlatform.runLater(this::handleArgument);\n\t}\n\n\tprivate void handleArgument()\n\t{\n\t\tvar userData = UiUtils.getUserData(rsIdTextArea);\n\t\tif (userData != null)\n\t\t{\n\t\t\tsetRsId((String) userData);\n\t\t\trsIdTextArea.setEditable(false);\n\t\t\tUiUtils.setAbsent(scanQrCode);\n\t\t}\n\t\telse\n\t\t{\n\t\t\trsIdTextArea.requestFocus();\n\t\t}\n\t}\n\n\tpublic void setRsId(String rsId)\n\t{\n\t\trsIdTextArea.setText(rsId);\n\t\taddButton.requestFocus();\n\t}\n\n\tprivate void addPeer()\n\t{\n\t\tvar profile = profileClient.create(rsIdTextArea.getText(), certIps.getSelectionModel().getSelectedIndex(), trust.getSelectionModel().getSelectedItem());\n\n\t\tprofile.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(cancelButton)))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void checkRsId(String rsId)\n\t{\n\t\tprofileClient.checkRsId(RSID_CLEANER.matcher(rsId).replaceAll(\"\"))\n\t\t\t\t.doOnSuccess(profile -> Platform.runLater(() ->\n\t\t\t\t{\n\t\t\t\t\tassert profile != null;\n\t\t\t\t\tstatus.setText(\"\");\n\t\t\t\t\taddButton.setDisable(false);\n\t\t\t\t\tUiUtils.clearError(rsIdTextArea, status);\n\n\t\t\t\t\tcertName.setText(profile.getName());\n\t\t\t\t\tcertId.setText(Id.toString(profile.getPgpIdentifier()));\n\t\t\t\t\tcertFingerprint.setText(profile.getProfileFingerprint().toString());\n\n\t\t\t\t\tcertIps.getItems().clear();\n\t\t\t\t\tprofile.getLocations().stream()\n\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t.ifPresent(location ->\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tcertLocId.setText(location.getLocationIdentifier().toString());\n\n\t\t\t\t\t\t\t\t// The same sorting is used in PeerConnectionJob/connectImmediately()\n\t\t\t\t\t\t\t\tvar allIps = location.getConnections().stream()\n\t\t\t\t\t\t\t\t\t\t.sorted(Comparator.comparing(Connection::isExternal).reversed())\n\t\t\t\t\t\t\t\t\t\t.map(Connection::getAddress)\n\t\t\t\t\t\t\t\t\t\t.toList();\n\n\t\t\t\t\t\t\t\tcertIps.getItems().addAll(allIps.stream()\n\t\t\t\t\t\t\t\t\t\t.map(s -> new AddressCountry(s, null))\n\t\t\t\t\t\t\t\t\t\t.toList());\n\n\t\t\t\t\t\t\t\tCompletableFuture.runAsync((NoSuppressedRunnable) () -> Platform.runLater(this::findFlags));\n\t\t\t\t\t\t\t});\n\t\t\t\t\tsetDefaultTrust(trust);\n\t\t\t\t\ttitledPane.setExpanded(true);\n\t\t\t\t}))\n\t\t\t\t.doOnError(_ -> Platform.runLater(() ->\n\t\t\t\t{\n\t\t\t\t\taddButton.setDisable(true);\n\t\t\t\t\tif (rsIdTextArea.getText().isBlank())\n\t\t\t\t\t{\n\t\t\t\t\t\tstatus.setText(\"\");\n\t\t\t\t\t\tUiUtils.clearError(rsIdTextArea, status);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tstatus.setText(bundle.getString(\"rs-id.add.invalid\"));\n\t\t\t\t\t\tUiUtils.highlightError(rsIdTextArea, status);\n\t\t\t\t\t}\n\t\t\t\t\ttitledPane.setExpanded(false);\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate static void setDefaultTrust(ChoiceBox<Trust> trust)\n\t{\n\t\ttrust.getItems().clear();\n\t\ttrust.getItems().addAll(Arrays.stream(Trust.values()).filter(t -> t != Trust.ULTIMATE).toList());\n\t\ttrust.getSelectionModel().select(Trust.UNKNOWN);\n\t}\n\n\tprivate void findFlags()\n\t{\n\t\tfor (var i = 0; i < certIps.getItems().size(); i++)\n\t\t{\n\t\t\tvar item = certIps.getItems().get(i);\n\t\t\tCountry country;\n\n\t\t\tif (OnionAddress.isValidAddress(item.address()))\n\t\t\t{\n\t\t\t\tcountry = Country.TOR;\n\t\t\t}\n\t\t\telse if (I2pAddress.isValidAddress(item.address()))\n\t\t\t{\n\t\t\t\tcountry = Country.I2P;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tvar hostPort = HostPort.parse(item.address());\n\n\t\t\t\tif (IP.isLanIp(hostPort.host()))\n\t\t\t\t{\n\t\t\t\t\tcountry = Country.LAN;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tcountry = findByGeoIp(hostPort.host());\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (country != null)\n\t\t\t{\n\t\t\t\tcertIps.getItems().set(i, new AddressCountry(item.address(), country));\n\t\t\t}\n\t\t}\n\t\tcertIps.getSelectionModel().select(0);\n\n\t\temptyIfNull(certIps.getItems()).stream()\n\t\t\t\t.min(Comparator.comparing(AddressCountry::country))\n\t\t\t\t.ifPresent(addressCountry -> imageFlag.setImage(FlagUtils.getFlag(imageFlag, addressCountry.country())));\n\t}\n\n\tprivate Country findByGeoIp(String ip)\n\t{\n\t\tvar countryResponse = geoIpClient.getIsoCountry(ip).block();\n\t\tif (countryResponse != null)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\treturn Country.valueOf(countryResponse.isoCountry().toUpperCase(Locale.ROOT));\n\t\t\t}\n\t\t\tcatch (IllegalArgumentException _)\n\t\t\t{\n\t\t\t\tlog.warn(\"Country not found for iso {}\", countryResponse.isoCountry());\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/id/AddressCell.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.id;\n\nimport javafx.scene.control.ListCell;\nimport javafx.scene.image.ImageView;\n\npublic class AddressCell extends ListCell<AddressCountry>\n{\n\tpublic AddressCell()\n\t{\n\t\tsuper();\n\t}\n\n\t@Override\n\tprotected void updateItem(AddressCountry item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.address());\n\t\tsetGraphic(empty ? null : new ImageView(FlagUtils.getFlag(this, item.country())));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/id/AddressConverter.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.id;\n\nimport javafx.util.StringConverter;\n\npublic class AddressConverter extends StringConverter<AddressCountry>\n{\n\t@Override\n\tpublic String toString(AddressCountry object)\n\t{\n\t\tif (object != null)\n\t\t{\n\t\t\treturn object.address();\n\t\t}\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic AddressCountry fromString(String string)\n\t{\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/id/AddressCountry.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.id;\n\nimport io.xeres.common.geoip.Country;\n\npublic record AddressCountry(String address, Country country)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/id/FlagUtils.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.id;\n\nimport io.xeres.common.geoip.Country;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport javafx.scene.Node;\nimport javafx.scene.control.Cell;\nimport javafx.scene.image.Image;\n\nimport java.util.Locale;\nimport java.util.Objects;\n\npublic final class FlagUtils\n{\n\tprivate FlagUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Image getFlag(Node node, Country country)\n\t{\n\t\tif (country != null)\n\t\t{\n\t\t\tvar flagPath = FlagUtils.class.getResourceAsStream(\"/image/flags/\" + country.name().toLowerCase(Locale.ROOT) + \".png\");\n\t\t\tif (flagPath != null)\n\t\t\t{\n\t\t\t\tif (node instanceof Cell<?> cell)\n\t\t\t\t{\n\t\t\t\t\tTooltipUtils.install(cell, country::toString, null);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tTooltipUtils.install(node, country.toString());\n\t\t\t\t}\n\t\t\t\treturn new Image(flagPath);\n\t\t\t}\n\t\t}\n\t\treturn new Image(Objects.requireNonNull(FlagUtils.class.getResourceAsStream(\"/image/flags/_unknown.png\")));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/messaging/BroadcastWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.messaging;\n\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.support.chat.ChatCommand;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.TextArea;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\n@Component\n@FxmlView(value = \"/view/messaging/broadcast.fxml\")\npublic class BroadcastWindowController implements WindowController\n{\n\t@FXML\n\tprivate Button send;\n\n\t@FXML\n\tprivate Button cancel;\n\n\t@FXML\n\tprivate TextArea textArea;\n\n\tprivate final MessageClient messageClient;\n\n\tpublic BroadcastWindowController(MessageClient messageClient)\n\t{\n\t\tthis.messageClient = messageClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tsend.setOnAction(event ->\n\t\t{\n\t\t\tvar message = new ChatMessage(ChatCommand.parseCommands(textArea.getText()));\n\t\t\tmessageClient.sendBroadcast(message);\n\t\t\tcancel.fire();\n\t\t});\n\n\t\ttextArea.textProperty().addListener(observable -> send.setDisable(textArea.getText().isBlank()));\n\t\tcancel.setOnAction(UiUtils::closeWindow);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/messaging/Destination.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.messaging;\n\nimport io.xeres.common.id.Identifier;\nimport org.apache.commons.lang3.StringUtils;\n\nclass Destination\n{\n\tprivate final Identifier identifier;\n\tprivate String name;\n\tprivate String place;\n\tprivate long locationId;\n\n\tpublic Destination(Identifier identifier)\n\t{\n\t\tthis.identifier = identifier;\n\t}\n\n\tpublic Identifier getIdentifier()\n\t{\n\t\treturn identifier;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic boolean hasPlace()\n\t{\n\t\treturn StringUtils.isNotEmpty(place);\n\t}\n\n\tpublic String getPlace()\n\t{\n\t\treturn place;\n\t}\n\n\tpublic void setPlace(String place)\n\t{\n\t\tthis.place = place;\n\t}\n\n\tpublic long getLocationId()\n\t{\n\t\treturn locationId;\n\t}\n\n\tpublic void setLocationId(long locationId)\n\t{\n\t\tthis.locationId = locationId;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.messaging;\n\nimport atlantafx.base.controls.Message;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.message.chat.ChatAvatar;\nimport io.xeres.common.message.chat.ChatBacklog;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.protocol.xrs.RsServiceType;\nimport io.xeres.common.rest.file.AddDownloadRequest;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.common.util.image.ImageUtils;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.client.preview.PreviewClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.controller.chat.ChatListView;\nimport io.xeres.ui.custom.InputAreaGroup;\nimport io.xeres.ui.custom.TypingNotificationView;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.custom.event.FileSelectedEvent;\nimport io.xeres.ui.custom.event.ImageSelectedEvent;\nimport io.xeres.ui.custom.event.StickerSelectedEvent;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.support.chat.ChatCommand;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.uri.FileUri;\nimport io.xeres.ui.support.uri.FileUriFactory;\nimport io.xeres.ui.support.uri.Uri;\nimport io.xeres.ui.support.uri.UriService;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.TextInputControlUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.animation.*;\nimport javafx.application.Platform;\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Alert;\nimport javafx.scene.control.TextInputControl;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.input.*;\nimport javafx.scene.layout.VBox;\nimport javafx.stage.Stage;\nimport javafx.util.Duration;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.reactive.function.client.WebClientResponseException;\nimport reactor.core.publisher.SignalType;\nimport reactor.core.scheduler.Schedulers;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.text.MessageFormat;\nimport java.time.Instant;\nimport java.util.ArrayDeque;\nimport java.util.List;\nimport java.util.Queue;\nimport java.util.ResourceBundle;\nimport java.util.concurrent.CompletableFuture;\n\nimport static io.xeres.common.message.chat.ChatConstants.TYPING_NOTIFICATION_DELAY;\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@FxmlView(value = \"/view/messaging/messaging.fxml\")\npublic class MessagingWindowController implements WindowController\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(MessagingWindowController.class);\n\n\tprivate static final int IMAGE_WIDTH_MAX = 800;\n\tprivate static final int IMAGE_HEIGHT_MAX = 600;\n\tprivate static final int STICKER_WIDTH_MAX = 256;\n\tprivate static final int STICKER_HEIGHT_MAX = 256;\n\tprivate static final int MESSAGE_MAXIMUM_SIZE = 260_000; // Maximum packet size is 262143 (that is the buffer a Retroshare pqistreamer allocates, so we leave some room)\n\n\tprivate static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination CTRL_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN);\n\tprivate static final KeyCodeCombination SHIFT_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN);\n\n\t@FXML\n\tprivate InputAreaGroup send;\n\n\t@FXML\n\tprivate TypingNotificationView notification;\n\n\t@FXML\n\tprivate VBox content;\n\n\t@FXML\n\tprivate Message notice;\n\n\tprivate Availability availability = Availability.AVAILABLE;\n\n\tprivate ChatListView receive;\n\n\tprivate final ProfileClient profileClient;\n\tprivate final IdentityClient identityClient;\n\tprivate final MarkdownService markdownService;\n\tprivate final WindowManager windowManager;\n\tprivate final UriService uriService;\n\tprivate final ResourceBundle bundle;\n\tprivate final Destination destination;\n\n\tprivate final MessageClient messageClient;\n\tprivate final ShareClient shareClient;\n\tprivate final ChatClient chatClient;\n\tprivate final GeneralClient generalClient;\n\tprivate final PreviewClient previewClient;\n\tprivate final ImageCache imageCache;\n\tprivate final LocationClient locationClient;\n\n\tprivate Instant lastTypingNotification = Instant.EPOCH;\n\n\tprivate Timeline lastTypingTimeline;\n\n\tprivate ParallelTransition sendAnimation;\n\n\tprivate final boolean isIncoming;\n\n\tprivate Queue<File> filesToSend;\n\n\tpublic MessagingWindowController(ProfileClient profileClient, IdentityClient identityClient, WindowManager windowManager, UriService uriService, MessageClient messageClient, ShareClient shareClient, MarkdownService markdownService, Identifier destinationIdentifier, ResourceBundle bundle, ChatClient chatClient, GeneralClient generalClient, PreviewClient previewClient, ImageCache imageCache, LocationClient locationClient, boolean isIncoming)\n\t{\n\t\tthis.profileClient = profileClient;\n\t\tthis.identityClient = identityClient;\n\t\tthis.windowManager = windowManager;\n\t\tthis.uriService = uriService;\n\t\tthis.messageClient = messageClient;\n\t\tthis.shareClient = shareClient;\n\t\tthis.markdownService = markdownService;\n\t\tthis.chatClient = chatClient;\n\t\tthis.bundle = bundle;\n\t\tthis.generalClient = generalClient;\n\t\tthis.previewClient = previewClient;\n\t\tthis.imageCache = imageCache;\n\t\tdestination = new Destination(destinationIdentifier);\n\t\tthis.locationClient = locationClient;\n\t\tthis.isIncoming = isIncoming;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tvar ownProfileResult = profileClient.getOwn();\n\t\townProfileResult.doOnSuccess(profile -> Platform.runLater(() -> {\n\t\t\t\t\tassert profile != null;\n\t\t\t\t\tsetupChatListView(profile.getName(), profile.getId()); // XXX: race condition here, sometimes showMessage() might be called before (and receive is null)\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tsend.addKeyFilter(this::handleInputKeys);\n\t\tsend.addEnhancedContextMenu(this::handlePaste);\n\n\t\tsend.addEventHandler(StickerSelectedEvent.STICKER_SELECTED, event -> {\n\t\t\tevent.consume();\n\t\t\tCompletableFuture.runAsync(() -> {\n\t\t\t\ttry (var inputStream = new FileInputStream(event.getPath().toFile()))\n\t\t\t\t{\n\t\t\t\t\tvar imageView = new ImageView(new Image(inputStream));\n\t\t\t\t\tPlatform.runLater(() -> sendStickerToMessage(imageView));\n\t\t\t\t}\n\t\t\t\tcatch (IOException e)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Couldn't send the sticker: {}\", e.getMessage());\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t\tsend.addEventHandler(ImageSelectedEvent.IMAGE_SELECTED, event -> {\n\t\t\tif (event.getFile().canRead())\n\t\t\t{\n\t\t\t\tCompletableFuture.runAsync(() -> {\n\t\t\t\t\ttry (var inputStream = new FileInputStream(event.getFile()))\n\t\t\t\t\t{\n\t\t\t\t\t\tvar imageView = new ImageView(new Image(inputStream));\n\t\t\t\t\t\tPlatform.runLater(() -> sendImageViewToMessage(imageView));\n\t\t\t\t\t}\n\t\t\t\t\tcatch (IOException e)\n\t\t\t\t\t{\n\t\t\t\t\t\tUiUtils.showAlert(Alert.AlertType.ERROR, MessageFormat.format(bundle.getString(\"file-requester.error\"), event.getFile(), e.getMessage()));\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\n\t\tsend.addEventHandler(FileSelectedEvent.FILE_SELECTED, event -> {\n\t\t\tif (event.getFile().canRead())\n\t\t\t{\n\t\t\t\tsendFile(event.getFile());\n\t\t\t}\n\t\t});\n\n\t\tsend.callPressedProperty().addListener((_, _, newValue) -> {\n\t\t\tif (Boolean.TRUE.equals(newValue))\n\t\t\t{\n\t\t\t\twindowManager.doVoip(destination.getIdentifier().toString(), null);\n\t\t\t}\n\t\t});\n\n\t\tlastTypingTimeline = new Timeline(\n\t\t\t\tnew KeyFrame(Duration.ZERO, _ -> notification.setText(MessageFormat.format(bundle.getString(\"chat.notification.typing\"), destination.getName()))),\n\t\t\t\tnew KeyFrame(Duration.seconds(TYPING_NOTIFICATION_DELAY.getSeconds())));\n\t\tlastTypingTimeline.setOnFinished(_ -> notification.setText(\"\"));\n\n\t\tsetupAnimations();\n\t}\n\n\tprivate void sendMessage(String message)\n\t{\n\t\tif (isEmpty(message))\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tvar chatMessage = new ChatMessage(ChatCommand.parseCommands(message));\n\t\tmessageClient.sendToDestination(destination.getIdentifier(), chatMessage);\n\t}\n\n\tprivate void sendTypingNotificationIfNeeded()\n\t{\n\t\tvar now = Instant.now();\n\t\tif (java.time.Duration.between(lastTypingNotification, now).compareTo(TYPING_NOTIFICATION_DELAY.minusSeconds(1)) > 0)\n\t\t{\n\t\t\tvar message = new ChatMessage();\n\t\t\tmessageClient.sendToDestination(destination.getIdentifier(), message);\n\t\t\tlastTypingNotification = now;\n\t\t}\n\t}\n\n\tprivate void setupChatListView(String nickname, long id)\n\t{\n\t\treceive = new ChatListView(nickname, id, markdownService, this::handleUriAction, generalClient, imageCache, windowManager, send);\n\t\tcontent.getChildren().add(1, receive.getChatView());\n\t\tcontent.setOnDragOver(event -> {\n\t\t\tif (event.getDragboard().hasFiles())\n\t\t\t{\n\t\t\t\tevent.acceptTransferModes(TransferMode.COPY_OR_MOVE);\n\t\t\t}\n\t\t\tevent.consume();\n\t\t});\n\t\tcontent.setOnDragDropped(event -> {\n\t\t\tvar files = event.getDragboard().getFiles();\n\t\t\tsendFiles(files);\n\t\t\tevent.setDropCompleted(true);\n\t\t\tevent.consume();\n\t\t});\n\t}\n\n\tprivate void sendFiles(List<File> files)\n\t{\n\t\tif (filesToSend == null)\n\t\t{\n\t\t\tfilesToSend = new ArrayDeque<>();\n\t\t}\n\t\tfilesToSend.addAll(CollectionUtils.emptyIfNull(files));\n\t\tsendNextFile();\n\t}\n\n\tprivate void sendFile(File file)\n\t{\n\t\tfilesToSend.add(file);\n\t\tsendNextFile();\n\t}\n\n\tprivate void sendNextFile()\n\t{\n\t\tvar file = filesToSend.poll();\n\t\tif (file != null)\n\t\t{\n\t\t\tshareClient.createTemporaryShare(file.getAbsolutePath())\n\t\t\t\t\t.doOnSuccess(result -> {\n\t\t\t\t\t\tassert result != null;\n\t\t\t\t\t\tsendMessage(FileUriFactory.generate(file.getName(), getFileSize(file.toPath()), Sha1Sum.fromString(result.hash())));\n\t\t\t\t\t})\n\t\t\t\t\t.doFinally(signalType -> {\n\t\t\t\t\t\tif (signalType != SignalType.CANCEL)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPlatform.runLater(this::sendNextFile);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tprivate static long getFileSize(Path path)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Files.size(path);\n\t\t}\n\t\tcatch (IOException _)\n\t\t{\n\t\t\tlog.error(\"Failed to get the file size of {}\", path);\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\tprivate void handleUriAction(Uri uri)\n\t{\n\t\tif (uri instanceof FileUri(String name, long size, Sha1Sum hash))\n\t\t{\n\t\t\twindowManager.openAddDownload(\n\t\t\t\t\tnew AddDownloadRequest(name, size, hash, destination.getIdentifier() instanceof LocationIdentifier locationIdentifier ? locationIdentifier : null));\n\t\t}\n\t\telse\n\t\t{\n\t\t\turiService.openUri(uri);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tif (destination.getIdentifier() instanceof LocationIdentifier locationIdentifier)\n\t\t{\n\t\t\tprofileClient.findByLocationIdentifier(locationIdentifier, true).collectList()\n\t\t\t\t\t.doOnSuccess(profiles -> {\n\t\t\t\t\t\tassert profiles != null;\n\t\t\t\t\t\tvar profile = profiles.stream().findFirst().orElseThrow();\n\t\t\t\t\t\tPlatform.runLater(() ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif (profile.getTrust() == Trust.FULL)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Only peers we trust can show previews\n\t\t\t\t\t\t\t\treceive.setPreviewClient(previewClient);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tvar location = profile.getLocations().getFirst();\n\t\t\t\t\t\t\tsetAvailability(location.isConnected() ? location.getAvailability() : Availability.OFFLINE);\n\t\t\t\t\t\t\tgetProfileImage(profile);\n\t\t\t\t\t\t\tdestination.setName(profile.getName());\n\t\t\t\t\t\t\tdestination.setPlace(location.getName());\n\t\t\t\t\t\t\tdestination.setLocationId(location.getId());\n\t\t\t\t\t\t\tupdateTitle();\n\t\t\t\t\t\t\treceive.installClearHistoryContextMenu(() -> chatClient.deleteChatBacklog(location.getId()).subscribe());\n\t\t\t\t\t\t\tchatClient.getChatBacklog(location.getId()).collectList()\n\t\t\t\t\t\t\t\t\t.doOnSuccess(backlogs -> Platform.runLater(() -> {\n\t\t\t\t\t\t\t\t\t\tassert backlogs != null;\n\t\t\t\t\t\t\t\t\t\tfillBacklog(backlogs);\n\t\t\t\t\t\t\t\t\t})) // No need to use userData to pass the incoming message, it's already in the backlog\n\t\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t\t});\n\t\t\t\t\t})\n\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t.subscribe();\n\t\t}\n\t\telse if (destination.getIdentifier() instanceof GxsId gxsId)\n\t\t{\n\t\t\tidentityClient.findByGxsId(gxsId).collectList()\n\t\t\t\t\t.doOnSuccess(gxsIds -> {\n\t\t\t\t\t\tassert gxsIds != null;\n\t\t\t\t\t\tvar identity = gxsIds.stream().findFirst().orElseThrow();\n\t\t\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\t\t\tdestination.setName(identity.getName());\n\t\t\t\t\t\t\tupdateTitle();\n\t\t\t\t\t\t\tfetchIdentityImage(identity.hasImage() ? identity.getId() : 0L, identity.getGxsId());\n\t\t\t\t\t\t\treceive.installClearHistoryContextMenu(() -> chatClient.deleteDistantChatBacklog(identity.getId()).subscribe());\n\t\t\t\t\t\t\tchatClient.getDistantChatBacklog(identity.getId()).collectList()\n\t\t\t\t\t\t\t\t\t.doOnSuccess(backlogs -> Platform.runLater(() -> {\n\t\t\t\t\t\t\t\t\t\tassert backlogs != null;\n\t\t\t\t\t\t\t\t\t\tfillBacklog(backlogs);\n\t\t\t\t\t\t\t\t\t})) // Incoming message already in the backlog\n\t\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t\t\tif (isIncoming)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tsetAvailability(Availability.AVAILABLE);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tchatClient.createDistantChat(identity.getId())\n\t\t\t\t\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> {\n\t\t\t\t\t\t\t\t\t\t\tsetAvailability(Availability.OFFLINE);\n\t\t\t\t\t\t\t\t\t\t\tnotification.setProgress(bundle.getString(\"messaging.tunneling\"));\n\t\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t\t.doOnError(WebClientResponseException.class, e -> {\n\t\t\t\t\t\t\t\t\t\t\tif (e.getStatusCode() == HttpStatus.CONFLICT)\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\tPlatform.runLater(() -> setAvailability(Availability.OFFLINE));\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t})\n\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t.subscribe();\n\n\t\t\tUiUtils.getWindow(send).setOnCloseRequest(event -> {\n\t\t\t\tif (availability != Availability.OFFLINE)\n\t\t\t\t{\n\t\t\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"messaging.closing-tunnel.confirm\"), () -> UiUtils.getWindow(send).hide());\n\t\t\t\t\tevent.consume();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Fetches the profile image. Tries to use the identity first, and if not possible,\n\t * uses a fallback to the old avatar sending system.\n\t *\n\t * @param profile the profile\n\t */\n\tprivate void getProfileImage(Profile profile)\n\t{\n\t\tprofileClient.findContactsForProfile(profile.getId()).collectList()\n\t\t\t\t.publishOn(Schedulers.boundedElastic())\n\t\t\t\t.doOnSuccess(contacts -> {\n\t\t\t\t\tassert contacts != null;\n\t\t\t\t\tif (contacts.isEmpty())\n\t\t\t\t\t{\n\t\t\t\t\t\tmessageClient.requestAvatar(destination.getIdentifier());\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tvar contact = contacts.getFirst();\n\t\t\t\t\t\tidentityClient.findById(contact.identityId())\n\t\t\t\t\t\t\t\t.doOnSuccess(identity -> {\n\t\t\t\t\t\t\t\t\tassert identity != null;\n\t\t\t\t\t\t\t\t\tfetchIdentityImage(identity.hasImage() ? identity.getId() : 0L, identity.getGxsId());\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void fetchIdentityImage(long identityId, GxsId gxsId)\n\t{\n\t\tString url;\n\n\t\tif (identityId != 0L)\n\t\t{\n\t\t\turl = RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/\" + identityId + \"/image\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\turl = RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/image?gxsId=\" + gxsId;\n\t\t}\n\t\tgeneralClient.getImage(url)\n\t\t\t\t.doOnSuccess(imageData -> Platform.runLater(() -> setWindowIcon(imageData)))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void setWindowIcon(byte[] imageData)\n\t{\n\t\tvar icon = new Image(new ByteArrayInputStream(imageData));\n\t\tvar stage = (Stage) getWindow(send);\n\t\tstage.getIcons().add(icon);\n\t}\n\n\t@Override\n\tpublic void onHidden()\n\t{\n\t\tif (destination.getIdentifier() instanceof GxsId gxsId)\n\t\t{\n\t\t\tidentityClient.findByGxsId(gxsId).collectList()\n\t\t\t\t\t.doOnSuccess(gxsIds -> {\n\t\t\t\t\t\tassert gxsIds != null;\n\t\t\t\t\t\tvar identity = gxsIds.stream().findFirst().orElseThrow();\n\t\t\t\t\t\tPlatform.runLater(() -> chatClient.closeDistantChat(identity.getId())\n\t\t\t\t\t\t\t\t.subscribe());\n\t\t\t\t\t})\n\t\t\t\t\t.subscribe();\n\t\t}\n\t}\n\n\tpublic void showMessage(ChatMessage message)\n\t{\n\t\tif (message != null)\n\t\t{\n\t\t\tif (message.isEmpty())\n\t\t\t{\n\t\t\t\tlastTypingTimeline.playFromStart();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (message.isOwn())\n\t\t\t\t{\n\t\t\t\t\treceive.addOwnMessage(message);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\treceive.addUserMessage(destination.getName(), message.getContent());\n\t\t\t\t}\n\t\t\t\tlastTypingTimeline.jumpTo(Duration.INDEFINITE);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void fillBacklog(List<ChatBacklog> messages)\n\t{\n\t\tmessages.forEach(message -> {\n\t\t\tif (message.own())\n\t\t\t{\n\t\t\t\treceive.addOwnMessage(message.created(), message.message());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treceive.addUserMessage(message.created(), destination.getName(), message.message());\n\t\t\t}\n\t\t});\n\t\treceive.jumpToBottom(true);\n\t}\n\n\tpublic void showAvatar(ChatAvatar chatAvatar)\n\t{\n\t\tif (chatAvatar.getImage() != null)\n\t\t{\n\t\t\tsetWindowIcon(chatAvatar.getImage());\n\t\t}\n\t}\n\n\tpublic void setAvailability(Availability availability)\n\t{\n\t\tthis.availability = availability;\n\t\tupdateTitle();\n\t\tnotification.stopProgress();\n\t}\n\n\tprivate void updateTitle()\n\t{\n\t\tvar stage = (Stage) getWindow(send);\n\t\tstage.setTitle(destination.getName() + (destination.hasPlace() ? (\" @ \" + destination.getPlace()) : \"\") + \" \" + getAvailability());\n\t}\n\n\tprivate String getAvailability()\n\t{\n\t\treturn switch (availability)\n\t\t{\n\t\t\tcase AVAILABLE ->\n\t\t\t{\n\t\t\t\tsetUserOnline(true);\n\t\t\t\tyield \"\";\n\t\t\t}\n\t\t\tcase AWAY ->\n\t\t\t{\n\t\t\t\tsetUserOnline(true);\n\t\t\t\tyield \"(\" + Availability.AWAY + \")\";\n\t\t\t}\n\t\t\tcase BUSY ->\n\t\t\t{\n\t\t\t\tsetUserOnline(true);\n\t\t\t\tyield \"(\" + Availability.BUSY + \")\";\n\t\t\t}\n\t\t\tcase OFFLINE ->\n\t\t\t{\n\t\t\t\tsetUserOnline(false);\n\t\t\t\tyield \"(\" + Availability.OFFLINE + \")\";\n\t\t\t}\n\t\t};\n\t}\n\n\tprivate void setUserOnline(boolean online)\n\t{\n\t\tUiUtils.setPresent(notice, !online);\n\t\tsend.setOffline(!online);\n\n\t\t// Enable the VoIP button only if we're a full node and the destination\n\t\t// has it enabled as well.\n\t\tif (!RemoteUtils.isRemoteUiClient() && online && destination.hasPlace())\n\t\t{\n\t\t\tlocationClient.isServiceSupported(destination.getLocationId(), RsServiceType.VOIP.getType())\n\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> send.setVoipCapable(true)))\n\t\t\t\t\t.doOnError(_ -> Platform.runLater(() -> send.setVoipCapable(false)))\n\t\t\t\t\t.subscribe();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsend.setVoipCapable(false);\n\t\t}\n\t}\n\n\tprivate void handleInputKeys(KeyEvent event)\n\t{\n\t\tif (PASTE_KEY.match(event))\n\t\t{\n\t\t\tif (handlePaste(send.getTextInputControl()))\n\t\t\t{\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t}\n\t\telse if (COPY_KEY.match(event))\n\t\t{\n\t\t\tif (receive.copy())\n\t\t\t{\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t}\n\t\telse if (CTRL_ENTER.match(event) || SHIFT_ENTER.match(event) && isNotBlank(send.getTextInputControl().getText()))\n\t\t{\n\t\t\tsend.getTextInputControl().insertText(send.getTextInputControl().getCaretPosition(), \"\\n\");\n\t\t\tsendTypingNotificationIfNeeded();\n\t\t\tevent.consume();\n\t\t}\n\t\telse if (event.getCode() == KeyCode.ENTER)\n\t\t{\n\t\t\tif (isNotBlank(send.getTextInputControl().getText()))\n\t\t\t{\n\t\t\t\tif (notice.isVisible())\n\t\t\t\t{\n\t\t\t\t\tsendAnimation.play();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tsendMessage(send.getTextInputControl().getText());\n\t\t\t\t\tsend.clear();\n\t\t\t\t\tlastTypingNotification = Instant.EPOCH;\n\t\t\t\t}\n\t\t\t}\n\t\t\tevent.consume();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsendTypingNotificationIfNeeded();\n\t\t}\n\t}\n\n\tprivate boolean handlePaste(TextInputControl textInputControl)\n\t{\n\t\tvar object = ClipboardUtils.getSupportedObjectFromClipboard();\n\t\treturn switch (object)\n\t\t{\n\t\t\tcase Image image ->\n\t\t\t{\n\t\t\t\tsendImageViewToMessage(new ImageView(image));\n\t\t\t\tyield true;\n\t\t\t}\n\t\t\tcase String string ->\n\t\t\t{\n\t\t\t\tTextInputControlUtils.pasteGuessedContent(textInputControl, string);\n\t\t\t\tyield true;\n\t\t\t}\n\t\t\tcase null, default -> false;\n\t\t};\n\t}\n\n\tprivate void sendImageViewToMessage(ImageView imageView)\n\t{\n\t\tImageViewUtils.limitMaximumImageSize(imageView, IMAGE_WIDTH_MAX * IMAGE_HEIGHT_MAX);\n\t\tvar imageData = ImageUtils.writeImage(SwingFXUtils.fromFXImage(imageView.getImage(), null), MESSAGE_MAXIMUM_SIZE);\n\t\tif (StringUtils.isNotEmpty(imageData))\n\t\t{\n\t\t\tsendMessage(\"<img src=\\\"\" + imageData + \"\\\"/>\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tUiUtils.showAlert(Alert.AlertType.WARNING, \"Couldn't compress PNG to a small enough size\");\n\t\t}\n\t\timageView.setImage(null);\n\t}\n\n\tprivate void sendStickerToMessage(ImageView imageView)\n\t{\n\t\tImageViewUtils.limitMaximumImageSize(imageView, STICKER_WIDTH_MAX * STICKER_HEIGHT_MAX);\n\t\tsendMessage(\"<img src=\\\"\" + ImageUtils.writeImage(SwingFXUtils.fromFXImage(imageView.getImage(), null), MESSAGE_MAXIMUM_SIZE) + \"\\\"/>\");\n\t\timageView.setImage(null);\n\t}\n\n\tprivate void setupAnimations()\n\t{\n\t\tvar translateTransition = new TranslateTransition(javafx.util.Duration.millis(50));\n\t\ttranslateTransition.setFromX(-5.0);\n\t\ttranslateTransition.setToX(5.0);\n\t\ttranslateTransition.setCycleCount(6);\n\t\ttranslateTransition.setAutoReverse(true);\n\t\ttranslateTransition.setInterpolator(Interpolator.LINEAR);\n\t\ttranslateTransition.setNode(send);\n\t\ttranslateTransition.setOnFinished(_ -> send.setTranslateX(0.0));\n\n\t\tvar fadeTransition = new FadeTransition(javafx.util.Duration.millis(100));\n\t\tfadeTransition.setByValue(-1.0);\n\t\tfadeTransition.setAutoReverse(true);\n\t\tfadeTransition.setCycleCount(4);\n\t\tfadeTransition.setInterpolator(Interpolator.EASE_IN);\n\t\tfadeTransition.setNode(notice);\n\n\t\tsendAnimation = new ParallelTransition(translateTransition, fadeTransition);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/qrcode/CameraWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.qrcode;\n\nimport com.github.sarxos.webcam.Webcam;\nimport com.github.sarxos.webcam.WebcamResolution;\nimport com.google.zxing.*;\nimport com.google.zxing.client.j2se.BufferedImageLuminanceSource;\nimport com.google.zxing.common.HybridBinarizer;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.controller.id.AddRsIdWindowController;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.beans.property.ObjectProperty;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.concurrent.Task;\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Label;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/qrcode/camera.fxml\")\npublic class CameraWindowController implements WindowController\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(CameraWindowController.class);\n\n\t@FXML\n\tprivate ImageView capturedImage;\n\n\t@FXML\n\tprivate Label error;\n\n\tprivate boolean stopCamera;\n\tprivate AddRsIdWindowController parentController;\n\tprivate final ObjectProperty<Image> imageProperty = new SimpleObjectProperty<>();\n\tprivate final ResourceBundle bundle;\n\n\tpublic CameraWindowController(ResourceBundle bundle)\n\t{\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tstopCamera = false;\n\n\t\tvar camera = Webcam.getDefault();\n\n\t\tif (camera != null)\n\t\t{\n\t\t\tinitializeCamera(camera);\n\t\t}\n\t\telse\n\t\t{\n\t\t\terror.setText(bundle.getString(\"qr-code.camera.error\"));\n\t\t\terror.setVisible(true);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onHidden()\n\t{\n\t\tstopCamera();\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tparentController = (AddRsIdWindowController) UiUtils.getUserData(capturedImage);\n\t}\n\n\tprivate void initializeCamera(Webcam camera)\n\t{\n\t\tvar cameraInitializer = new Task<Void>()\n\t\t{\n\t\t\t@Override\n\t\t\tprotected Void call()\n\t\t\t{\n\t\t\t\tString rsId = null;\n\t\t\t\tvar multiFormatReader = new MultiFormatReader();\n\t\t\t\tmultiFormatReader.setHints(Map.of(\n\t\t\t\t\t\tDecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE)));\n\n\t\t\t\t// Built-in driver only supports 640x480 as maximum, but\n\t\t\t\t// this is usually enough for QR code scanning.\n\t\t\t\tcamera.setViewSize(WebcamResolution.VGA.getSize());\n\t\t\t\tcamera.open();\n\t\t\t\twhile (!stopCamera)\n\t\t\t\t{\n\t\t\t\t\ttry\n\t\t\t\t\t{\n\t\t\t\t\t\tvar grabbedImage = camera.getImage();\n\t\t\t\t\t\tif (grabbedImage != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPlatform.runLater(() -> imageProperty.set(SwingFXUtils.toFXImage(grabbedImage, null)));\n\n\t\t\t\t\t\t\tvar source = new BufferedImageLuminanceSource(grabbedImage);\n\t\t\t\t\t\t\tvar bitmap = new BinaryBitmap(new HybridBinarizer(source));\n\n\t\t\t\t\t\t\tgrabbedImage.flush();\n\n\t\t\t\t\t\t\tvar result = multiFormatReader.decodeWithState(bitmap);\n\n\t\t\t\t\t\t\trsId = result.getText();\n\t\t\t\t\t\t\tlog.debug(\"Found qr code: {}\", rsId);\n\t\t\t\t\t\t\tstopCamera();\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.warn(\"Empty image!?\");\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tcatch (NotFoundException _)\n\t\t\t\t\t{\n\t\t\t\t\t\t// No QR code was found on the image\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcamera.close();\n\n\t\t\t\tString finalRsId = rsId;\n\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\timageProperty.set(null);\n\n\t\t\t\t\tif (finalRsId != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tparentController.setRsId(finalRsId);\n\t\t\t\t\t\tUiUtils.closeWindow(capturedImage);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t}\n\t\t};\n\t\tcapturedImage.imageProperty().bind(imageProperty);\n\t\tThread.ofVirtual().name(\"Camera Handler\").start(cameraInitializer);\n\t}\n\n\tprivate void stopCamera()\n\t{\n\t\tstopCamera = true;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/qrcode/QrCodeWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.qrcode;\n\nimport io.xeres.common.AppName;\nimport io.xeres.common.rest.location.RSIdResponse;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.ResizeableImageView;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.print.PrinterJob;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.Label;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.transform.Scale;\nimport javafx.stage.FileChooser;\nimport javafx.stage.FileChooser.ExtensionFilter;\nimport javafx.stage.Window;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport javax.imageio.ImageIO;\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.rest.PathConfig.LOCATIONS_PATH;\n\n@Component\n@FxmlView(value = \"/view/qrcode/qrcode.fxml\")\npublic class QrCodeWindowController implements WindowController\n{\n\tpublic static final double PRINTER_DPI = 72.0; // JavaFX uses 72 DPI for all printers\n\tpublic static final double CREDIT_CARD_WIDTH = 3.37;\n\tpublic static final double CREDIT_CARD_HEIGHT = 2.125;\n\n\t@FXML\n\tprivate ResizeableImageView ownQrCode;\n\n\t@FXML\n\tprivate Button printButton;\n\n\t@FXML\n\tprivate Button saveButton;\n\n\t@FXML\n\tprivate Button closeButton;\n\n\t@FXML\n\tprivate Label status;\n\n\tprivate RSIdResponse rsIdResponse;\n\n\tprivate final GeneralClient generalClient;\n\n\tprivate final ResourceBundle bundle;\n\n\tpublic QrCodeWindowController(GeneralClient generalClient, ResourceBundle bundle)\n\t{\n\t\tthis.generalClient = generalClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\townQrCode.setLoader(url -> generalClient.getImage(url).block());\n\t\tprintButton.setOnAction(event -> showPrintSetupThenPrint(UiUtils.getWindow(event)));\n\t\tsaveButton.setOnAction(event -> saveAsPng(UiUtils.getWindow(event)));\n\t\tcloseButton.setOnAction(UiUtils::closeWindow);\n\n\t\tPlatform.runLater(() -> closeButton.requestFocus());\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = UiUtils.getUserData(ownQrCode);\n\t\tif (userData == null)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Missing RsIdResponse\");\n\t\t}\n\n\t\trsIdResponse = (RSIdResponse) userData;\n\n\t\townQrCode.setUrl(LOCATIONS_PATH + \"/\" + 1L + \"/rs-id/qr-code\");\n\t}\n\n\tprivate void showPrintSetupThenPrint(Window window)\n\t{\n\t\tvar printerJob = PrinterJob.createPrinterJob();\n\t\tif (printerJob.showPrintDialog(window))\n\t\t{\n\t\t\tprint(printerJob, ownQrCode);\n\t\t}\n\t}\n\n\tprivate void print(PrinterJob printerJob, ImageView qrCode)\n\t{\n\t\tstatus.textProperty().bind(printerJob.jobStatusProperty().asString());\n\n\t\tvar loader = new FXMLLoader(QrCodeWindowController.class.getResource(\"/view/qrcode/qrprint.fxml\"), bundle);\n\n\t\ttry\n\t\t{\n\t\t\tHBox view = loader.load();\n\n\t\t\tvar controller = (QrPrintController) loader.getController();\n\n\t\t\tcontroller.setImage(qrCode.getImage());\n\t\t\tcontroller.setProfileName(rsIdResponse.name());\n\t\t\tcontroller.setLocationName(rsIdResponse.location());\n\n\t\t\tvar sizeX = CREDIT_CARD_WIDTH * PRINTER_DPI;\n\t\t\tvar sizeY = CREDIT_CARD_HEIGHT * PRINTER_DPI;\n\n\t\t\tvar pageLayout = printerJob.getPrinter().getDefaultPageLayout();\n\n\t\t\tif (sizeX > pageLayout.getPrintableWidth() || sizeY > pageLayout.getPrintableHeight())\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"QR code card is too big for the printer paper size\");\n\t\t\t}\n\n\t\t\tvar scale = new Scale(sizeX / view.getPrefWidth(), sizeY / view.getPrefHeight());\n\t\t\tview.getTransforms().add(scale);\n\n\t\t\t// See https://bugs.openjdk.org/browse/JDK-8089053 about the \"unexpected PG access\" print out.\n\t\t\t// There's nothing that can be done about it and it's harmless.\n\t\t\tif (printerJob.printPage(view))\n\t\t\t{\n\t\t\t\tprinterJob.endJob();\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tprivate void saveAsPng(Window window)\n\t{\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"qr-code.save-as-png\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tfileChooser.getExtensionFilters().add(new ExtensionFilter(bundle.getString(\"file-requester.png\"), \"*.png\"));\n\t\tfileChooser.setInitialFileName(AppName.NAME + \"ID_\" + rsIdResponse.name() + \"@\" + rsIdResponse.location() + \".png\");\n\t\tvar selectedFile = fileChooser.showSaveDialog(window);\n\t\tif (selectedFile != null && (!selectedFile.exists() || selectedFile.canWrite()))\n\t\t{\n\t\t\tvar bufferedImage = SwingFXUtils.fromFXImage(ownQrCode.snapshot(null, null), null);\n\t\t\ttry\n\t\t\t{\n\t\t\t\tImageIO.write(bufferedImage, \"PNG\", selectedFile);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/qrcode/QrPrintController.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.qrcode;\n\nimport io.xeres.ui.controller.Controller;\nimport javafx.fxml.FXML;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.text.Text;\n\npublic class QrPrintController implements Controller\n{\n\t@FXML\n\tprivate ImageView qrCode;\n\n\t@FXML\n\tprivate Text profileText;\n\n\t@FXML\n\tprivate Text locationText;\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\t// Nothing to do\n\t}\n\n\tpublic void setImage(Image image)\n\t{\n\t\tqrCode.setImage(image);\n\t}\n\n\tpublic void setProfileName(String name)\n\t{\n\t\tprofileText.setText(name);\n\t}\n\n\tpublic void setLocationName(String name)\n\t{\n\t\tlocationText.setText(name);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsCell.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport javafx.scene.control.ListCell;\n\nclass SettingsCell extends ListCell<SettingsGroup>\n{\n\tSettingsCell()\n\t{\n\t\tsuper();\n\t}\n\n\t@Override\n\tprotected void updateItem(SettingsGroup item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetText(empty ? null : item.name());\n\t\tsetGraphic(empty ? null : item.graphic());\n\t\tsetGraphicTextGap(8.0);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsController.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.model.settings.Settings;\n\npublic interface SettingsController extends Controller\n{\n\tvoid onLoad(Settings settings);\n\n\tSettings onSave();\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsGeneralController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.common.rest.config.Capabilities;\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.theme.AppTheme;\nimport io.xeres.ui.support.theme.AppThemeManager;\nimport io.xeres.ui.support.updater.UpdateService;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.CheckBox;\nimport javafx.scene.control.ComboBox;\nimport javafx.scene.control.Label;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Arrays;\n\nimport static io.xeres.ui.support.preference.PreferenceUtils.UPDATE_CHECK;\n\n@Component\n@FxmlView(value = \"/view/settings/settings_general.fxml\")\npublic class SettingsGeneralController implements SettingsController\n{\n\t@FXML\n\tprivate ComboBox<AppTheme> themeSelector;\n\n\t@FXML\n\tprivate CheckBox autoStartEnabled;\n\n\t@FXML\n\tprivate CheckBox checkForUpdates;\n\n\t@FXML\n\tprivate Label autoStartNotAvailable;\n\n\tprivate Settings settings;\n\n\tprivate final ConfigClient configClient;\n\tprivate final AppThemeManager appThemeManager;\n\tprivate final UpdateService updateService;\n\n\tpublic SettingsGeneralController(ConfigClient configClient, AppThemeManager appThemeManager, UpdateService updateService)\n\t{\n\t\tthis.configClient = configClient;\n\t\tthis.appThemeManager = appThemeManager;\n\t\tthis.updateService = updateService;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tthemeSelector.setButtonCell(new ThemeCell(themeSelector));\n\t\tthemeSelector.setCellFactory(_ -> new ThemeCell(themeSelector));\n\t\tthemeSelector.getItems().addAll(Arrays.stream(AppTheme.values()).toList());\n\t\tvar currentTheme = appThemeManager.getCurrentTheme();\n\t\tthemeSelector.getSelectionModel().select(currentTheme);\n\t\tthemeSelector.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> appThemeManager.changeTheme(newValue));\n\n\t\tconfigClient.getCapabilities()\n\t\t\t\t.doOnSuccess(capabilities -> Platform.runLater(() -> {\n\t\t\t\t\tassert capabilities != null;\n\t\t\t\t\tif (capabilities.contains(Capabilities.AUTOSTART))\n\t\t\t\t\t{\n\t\t\t\t\t\tautoStartEnabled.setDisable(false);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tautoStartNotAvailable.setVisible(true);\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\t@Override\n\tpublic void onLoad(Settings settings)\n\t{\n\t\tthis.settings = settings;\n\n\t\tautoStartEnabled.setSelected(settings.isAutoStartEnabled());\n\n\t\tcheckForUpdates.setSelected(updateService.isAutomaticallyCheckingForUpdates(PreferenceUtils.getPreferences().node(UPDATE_CHECK)));\n\t}\n\n\t@Override\n\tpublic Settings onSave()\n\t{\n\t\tsettings.setAutoStartEnabled(autoStartEnabled.isSelected());\n\n\t\tupdateService.setAutomaticCheckForUpdates(PreferenceUtils.getPreferences().node(UPDATE_CHECK), checkForUpdates.isSelected());\n\n\t\treturn settings;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsGroup.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport javafx.scene.Node;\n\nrecord SettingsGroup(String name, Node graphic, Class<? extends SettingsController> controllerClass)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsNetworksController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.support.util.TextFieldUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.CheckBox;\nimport javafx.scene.control.TextField;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\n@Component\n@FxmlView(value = \"/view/settings/settings_networks.fxml\")\npublic class SettingsNetworksController implements SettingsController\n{\n\t@FXML\n\tprivate TextField torSocksHost;\n\n\t@FXML\n\tprivate TextField torSocksPort;\n\n\t@FXML\n\tprivate TextField i2pSocksHost;\n\n\t@FXML\n\tprivate TextField i2pSocksPort;\n\n\t@FXML\n\tprivate CheckBox upnpEnabled;\n\n\t@FXML\n\tprivate TextField externalIp;\n\n\t@FXML\n\tprivate TextField externalPort;\n\n\t@FXML\n\tprivate CheckBox broadcastDiscoveryEnabled;\n\n\t@FXML\n\tprivate TextField internalIp;\n\n\t@FXML\n\tprivate TextField internalPort;\n\n\t@FXML\n\tprivate CheckBox dhtEnabled;\n\n\tprivate Settings settings;\n\n\tprivate final ConfigClient configClient;\n\n\tpublic SettingsNetworksController(ConfigClient configClient)\n\t{\n\t\tthis.configClient = configClient;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tTextFieldUtils.setHost(torSocksHost);\n\t\tTextFieldUtils.setNumeric(torSocksPort, 0, 6);\n\n\t\tTextFieldUtils.setHost(i2pSocksHost);\n\t\tTextFieldUtils.setNumeric(i2pSocksPort, 0, 6);\n\n\t\tconfigClient.getExternalIpAddress()\n\t\t\t\t.doOnSuccess(ipAddressResponse -> Platform.runLater(() -> {\n\t\t\t\t\tassert ipAddressResponse != null;\n\t\t\t\t\texternalIp.setText(ipAddressResponse.ip());\n\t\t\t\t\texternalPort.setText(String.valueOf(ipAddressResponse.port()));\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\n\t\tconfigClient.getInternalIpAddress()\n\t\t\t\t.doOnSuccess(ipAddressResponse -> Platform.runLater(() -> {\n\t\t\t\t\tassert ipAddressResponse != null;\n\t\t\t\t\tinternalIp.setText(ipAddressResponse.ip());\n\t\t\t\t\tinternalPort.setText(String.valueOf(ipAddressResponse.port()));\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\t@Override\n\tpublic void onLoad(Settings settings)\n\t{\n\t\tthis.settings = settings;\n\n\t\ttorSocksHost.setText(settings.getTorSocksHost());\n\t\tif (settings.getTorSocksPort() != 0)\n\t\t{\n\t\t\ttorSocksPort.setText(String.valueOf(settings.getTorSocksPort()));\n\t\t}\n\n\t\ti2pSocksHost.setText(settings.getI2pSocksHost());\n\t\tif (settings.getI2pSocksPort() != 0)\n\t\t{\n\t\t\ti2pSocksPort.setText(String.valueOf(settings.getI2pSocksPort()));\n\t\t}\n\n\t\tupnpEnabled.setSelected(settings.isUpnpEnabled());\n\n\t\tbroadcastDiscoveryEnabled.setSelected(settings.isBroadcastDiscoveryEnabled());\n\n\t\tdhtEnabled.setSelected(settings.isDhtEnabled());\n\t}\n\n\t@Override\n\tpublic Settings onSave()\n\t{\n\t\tsettings.setTorSocksHost(TextFieldUtils.getString(torSocksHost));\n\t\tsettings.setTorSocksPort(limitPort(TextFieldUtils.getAsNumber(torSocksPort)));\n\n\t\tsettings.setI2pSocksHost(TextFieldUtils.getString(i2pSocksHost));\n\t\tsettings.setI2pSocksPort(limitPort(TextFieldUtils.getAsNumber(i2pSocksPort)));\n\n\t\tsettings.setUpnpEnabled(upnpEnabled.isSelected());\n\n\t\tsettings.setBroadcastDiscoveryEnabled(broadcastDiscoveryEnabled.isSelected());\n\n\t\tsettings.setDhtEnabled(dhtEnabled.isSelected());\n\n\t\treturn settings;\n\t}\n\n\tprivate int limitPort(int port)\n\t{\n\t\tif (port > 65535)\n\t\t{\n\t\t\tport = 65535;\n\t\t}\n\t\treturn port;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsNotificationController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.support.notification.NotificationSettings;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.CheckBox;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\n@Component\n@FxmlView(value = \"/view/settings/settings_notifications.fxml\")\npublic class SettingsNotificationController implements SettingsController\n{\n\t@FXML\n\tprivate CheckBox showConnections;\n\n\t@FXML\n\tprivate CheckBox showBroadcasts;\n\n\t@FXML\n\tprivate CheckBox showDiscovery;\n\n\tprivate final NotificationSettings notificationSettings;\n\n\tpublic SettingsNotificationController(NotificationSettings notificationSettings)\n\t{\n\t\tthis.notificationSettings = notificationSettings;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\n\t}\n\n\t@Override\n\tpublic void onLoad(Settings settings)\n\t{\n\t\tshowConnections.setSelected(notificationSettings.isConnectionEnabled());\n\t\tshowBroadcasts.setSelected(notificationSettings.isBroadcastsEnabled());\n\t\tshowDiscovery.setSelected(notificationSettings.isDiscoveryEnabled());\n\t}\n\n\t@Override\n\tpublic Settings onSave()\n\t{\n\t\tnotificationSettings.setConnectionEnabled(showConnections.isSelected());\n\t\tnotificationSettings.setBroadcastsEnabled(showBroadcasts.isSelected());\n\t\tnotificationSettings.setDiscoveryEnabled(showDiscovery.isSelected());\n\n\t\tnotificationSettings.save();\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsRemoteController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport atlantafx.base.controls.PasswordTextField;\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.custom.DisclosedHyperlink;\nimport io.xeres.ui.custom.ReadOnlyTextField;\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.support.tray.TrayService;\nimport io.xeres.ui.support.util.TextFieldUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport jakarta.annotation.Nullable;\nimport javafx.application.HostServices;\nimport javafx.fxml.FXML;\nimport javafx.scene.Cursor;\nimport javafx.scene.control.CheckBox;\nimport javafx.scene.control.TextField;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Objects;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.common.properties.StartupProperties.Property.CONTROL_PORT;\nimport static org.apache.commons.lang3.StringUtils.isBlank;\n\n@Component\n@FxmlView(value = \"/view/settings/settings_remote.fxml\")\npublic class SettingsRemoteController implements SettingsController\n{\n\t@FXML\n\tprivate CheckBox remoteEnabled;\n\n\t@FXML\n\tprivate PasswordTextField password;\n\n\t@FXML\n\tprivate DisclosedHyperlink viewApi;\n\n\t@FXML\n\tprivate TextField port;\n\n\t@FXML\n\tprivate ReadOnlyTextField username;\n\n\t@FXML\n\tprivate CheckBox remoteUpnpEnabled;\n\n\tprivate boolean noUpnp;\n\n\tprivate Settings settings;\n\n\tprivate boolean originalRemoteEnabled;\n\n\tprivate String originalPassword;\n\n\tprivate final TrayService trayService;\n\tprivate final HostServices hostServices;\n\tprivate final ResourceBundle bundle;\n\n\tpublic SettingsRemoteController(TrayService trayService, @SuppressWarnings(\"SpringJavaInjectionPointsAutowiringInspection\") @Nullable HostServices hostServices, ResourceBundle bundle)\n\t{\n\t\tthis.trayService = trayService;\n\t\tthis.hostServices = hostServices;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tTextFieldUtils.setNumeric(port, 0, 6);\n\n\t\tvar icon = new FontIcon(\"mdi2e-eye-off\");\n\t\ticon.setCursor(Cursor.HAND);\n\t\tUiUtils.setOnPrimaryMouseClicked(icon, event -> {\n\t\t\ticon.setIconLiteral(password.getRevealPassword() ? \"mdi2e-eye-off\" : \"mdi2e-eye\");\n\t\t\tpassword.setRevealPassword(!password.getRevealPassword());\n\t\t});\n\t\tpassword.setRight(icon);\n\n\t\tremoteEnabled.setOnAction(actionEvent -> checkDisabled());\n\n\t\tUiUtils.linkify(viewApi, hostServices);\n\t\tviewApi.setUri(RemoteUtils.getControlUrl() + \"/swagger-ui/index.html\");\n\t}\n\n\t@Override\n\tpublic void onLoad(Settings settings)\n\t{\n\t\tthis.settings = settings;\n\n\t\tnoUpnp = !settings.isUpnpEnabled();\n\n\t\tremoteEnabled.setSelected(settings.isRemoteEnabled());\n\t\tremoteUpnpEnabled.setSelected(settings.isUpnpRemoteEnabled());\n\t\tcheckDisabled();\n\t\tpassword.setText(settings.getRemotePassword());\n\t\tport.setText(String.valueOf(StartupProperties.getInteger(CONTROL_PORT)));\n\n\t\toriginalRemoteEnabled = settings.isRemoteEnabled();\n\t\toriginalPassword = settings.getRemotePassword();\n\t}\n\n\t@Override\n\tpublic Settings onSave()\n\t{\n\t\tvar portChanged = false;\n\n\t\tsettings.setRemotePassword(isBlank(password.getPassword()) ? null : password.getPassword());\n\t\tsettings.setRemoteEnabled(remoteEnabled.isSelected());\n\t\tsettings.setUpnpRemoteEnabled(remoteUpnpEnabled.isSelected());\n\t\tif (!port.getText().isEmpty())\n\t\t{\n\t\t\tvar portValue = Integer.parseInt(port.getText());\n\t\t\tif (portValue >= 1025 && portValue <= 65535 && portValue != Objects.requireNonNull(StartupProperties.getInteger(CONTROL_PORT)))\n\t\t\t{\n\t\t\t\tsettings.setRemotePort(portValue);\n\t\t\t\tportChanged = true;\n\t\t\t}\n\t\t}\n\n\t\tif (originalRemoteEnabled != settings.isRemoteEnabled() || !originalPassword.equals(settings.getRemotePassword()) || portChanged)\n\t\t{\n\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"settings.remote.restart\"), trayService::exitApplication);\n\t\t}\n\n\t\treturn settings;\n\t}\n\n\tprivate void checkDisabled()\n\t{\n\t\tvar selected = remoteEnabled.isSelected();\n\t\tport.setDisable(!selected);\n\t\tusername.setDisable(!selected);\n\t\tpassword.setDisable(!selected);\n\t\tremoteUpnpEnabled.setDisable(noUpnp || !selected);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsSoundController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.support.sound.SoundPlayerService;\nimport io.xeres.ui.support.sound.SoundSettings;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.CheckBox;\nimport javafx.scene.control.TextField;\nimport javafx.stage.FileChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.nio.file.Path;\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/settings/settings_sound.fxml\")\npublic class SettingsSoundController implements SettingsController\n{\n\t@FXML\n\tprivate CheckBox messageEnabled;\n\n\t@FXML\n\tprivate CheckBox highlightEnabled;\n\n\t@FXML\n\tprivate CheckBox friendEnabled;\n\n\t@FXML\n\tprivate CheckBox downloadEnabled;\n\n\t@FXML\n\tprivate CheckBox ringingEnabled;\n\n\t@FXML\n\tprivate TextField messageFile;\n\n\t@FXML\n\tprivate TextField highlightFile;\n\n\t@FXML\n\tprivate TextField friendFile;\n\n\t@FXML\n\tprivate TextField downloadFile;\n\n\t@FXML\n\tprivate TextField ringingFile;\n\n\t@FXML\n\tprivate Button messageFileSelector;\n\n\t@FXML\n\tprivate Button highlightFileSelector;\n\n\t@FXML\n\tprivate Button friendFileSelector;\n\n\t@FXML\n\tprivate Button downloadFileSelector;\n\n\t@FXML\n\tprivate Button ringingFileSelector;\n\n\t@FXML\n\tprivate Button messagePlay;\n\n\t@FXML\n\tprivate Button highlightPlay;\n\n\t@FXML\n\tprivate Button friendPlay;\n\n\t@FXML\n\tprivate Button downloadPlay;\n\n\t@FXML\n\tprivate Button ringingPlay;\n\n\tprivate final ResourceBundle bundle;\n\tprivate final SoundSettings soundSettings;\n\tprivate final SoundPlayerService soundPlayerService;\n\n\tpublic SettingsSoundController(ResourceBundle bundle, SoundSettings soundSettings, SoundPlayerService soundPlayerService)\n\t{\n\t\tthis.bundle = bundle;\n\t\tthis.soundSettings = soundSettings;\n\t\tthis.soundPlayerService = soundPlayerService;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tinitializeSoundPath(messageEnabled, messageFile, messageFileSelector, messagePlay);\n\t\tinitializeSoundPath(highlightEnabled, highlightFile, highlightFileSelector, highlightPlay);\n\t\tinitializeSoundPath(friendEnabled, friendFile, friendFileSelector, friendPlay);\n\t\tinitializeSoundPath(downloadEnabled, downloadFile, downloadFileSelector, downloadPlay);\n\t\tinitializeSoundPath(ringingEnabled, ringingFile, ringingFileSelector, ringingPlay);\n\t}\n\n\tprivate void initializeSoundPath(CheckBox checkbox, TextField path, Button pathSelector, Button playButton)\n\t{\n\t\tcheckbox.selectedProperty().addListener((_, _, newValue) -> {\n\t\t\tpath.setDisable(!newValue);\n\t\t\tpathSelector.setDisable(!newValue);\n\t\t\tplayButton.setDisable(!newValue);\n\t\t});\n\t\tpathSelector.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"file-requester.select-sound-title\"));\n\t\t\tfileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(bundle.getString(\"file-requester.sounds\"), \"*.aif\", \"*.aiff\", \"*.mp3\", \"*.mp4\", \"*.wav\"));\n\t\t\tif (!path.getText().isEmpty())\n\t\t\t{\n\t\t\t\tfileChooser.setInitialFileName(path.getText());\n\t\t\t\tsetInitialDirectoryIfExists(fileChooser, path.getText());\n\t\t\t}\n\t\t\tvar selectedFile = fileChooser.showOpenDialog(UiUtils.getWindow(event));\n\t\t\tif (selectedFile != null && selectedFile.isFile())\n\t\t\t{\n\t\t\t\tpath.setText(selectedFile.getAbsolutePath());\n\t\t\t}\n\t\t});\n\t\tplayButton.setOnAction(_ -> soundPlayerService.play(path.getText()));\n\t}\n\n\tprivate void setInitialDirectoryIfExists(FileChooser fileChooser, String filePath)\n\t{\n\t\tvar path = Path.of(filePath);\n\t\tvar parent = path.getParent();\n\t\tif (parent == null && !path.isAbsolute())\n\t\t{\n\t\t\t// Try to find the proper path (can happen on Windows when we're auto started)\n\t\t\tvar home = OsUtils.getApplicationHome();\n\t\t\tpath = Path.of(home.toString(), filePath);\n\t\t\tparent = path.getParent();\n\t\t}\n\t\tif (parent != null)\n\t\t{\n\t\t\tvar file = parent.toFile();\n\t\t\tChooserUtils.setInitialDirectory(fileChooser, file);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void onLoad(Settings settings)\n\t{\n\t\tmessageEnabled.setSelected(soundSettings.isMessageEnabled());\n\t\thighlightEnabled.setSelected(soundSettings.isHighlightEnabled());\n\t\tfriendEnabled.setSelected(soundSettings.isFriendEnabled());\n\t\tdownloadEnabled.setSelected(soundSettings.isDownloadEnabled());\n\t\tringingEnabled.setSelected(soundSettings.isRingingEnabled());\n\n\t\tmessageFile.setText(soundSettings.getMessageFile());\n\t\thighlightFile.setText(soundSettings.getHighlightFile());\n\t\tfriendFile.setText(soundSettings.getFriendFile());\n\t\tdownloadFile.setText(soundSettings.getDownloadFile());\n\t\tringingFile.setText(soundSettings.getRingingFile());\n\t}\n\n\t@Override\n\tpublic Settings onSave()\n\t{\n\t\tsoundSettings.setMessageEnabled(messageEnabled.isSelected());\n\t\tsoundSettings.setHighlightEnabled(highlightEnabled.isSelected());\n\t\tsoundSettings.setFriendEnabled(friendEnabled.isSelected());\n\t\tsoundSettings.setDownloadEnabled(downloadEnabled.isSelected());\n\t\tsoundSettings.setRingingEnabled(ringingEnabled.isSelected());\n\n\t\tsoundSettings.setMessageFile(messageFile.getText());\n\t\tsoundSettings.setHighlightFile(highlightFile.getText());\n\t\tsoundSettings.setFriendFile(friendFile.getText());\n\t\tsoundSettings.setDownloadFile(downloadFile.getText());\n\t\tsoundSettings.setRingingFile(ringingFile.getText());\n\n\t\tsoundSettings.save();\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsTransferController.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.model.settings.Settings;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.TextFieldUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.TextField;\nimport javafx.stage.DirectoryChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\nimport static javafx.scene.control.Alert.AlertType.INFORMATION;\n\n@Component\n@FxmlView(value = \"/view/settings/settings_transfer.fxml\")\npublic class SettingsTransferController implements SettingsController\n{\n\t@FXML\n\tprivate TextField incomingDirectory;\n\n\t@FXML\n\tprivate Button incomingDirectorySelector;\n\n\tprivate Settings settings;\n\n\tprivate final ResourceBundle bundle;\n\n\tpublic SettingsTransferController(ResourceBundle bundle)\n\t{\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tincomingDirectorySelector.setOnAction(event -> {\n\t\t\tif (RemoteUtils.isRemoteUiClient())\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(INFORMATION, bundle.getString(\"settings.directory.no-remote\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar directoryChooser = new DirectoryChooser();\n\t\t\tdirectoryChooser.setTitle(bundle.getString(\"settings.transfer.select-incoming\"));\n\t\t\tif (settings.hasIncomingDirectory())\n\t\t\t{\n\t\t\t\tChooserUtils.setInitialDirectory(directoryChooser, settings.getIncomingDirectory());\n\t\t\t}\n\t\t\tvar selectedDirectory = directoryChooser.showDialog(UiUtils.getWindow(event));\n\t\t\tif (selectedDirectory != null && selectedDirectory.isDirectory())\n\t\t\t{\n\t\t\t\tincomingDirectory.setText(selectedDirectory.getAbsolutePath());\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic void onLoad(Settings settings)\n\t{\n\t\tthis.settings = settings;\n\n\t\tincomingDirectory.setText(settings.getIncomingDirectory());\n\t}\n\n\t@Override\n\tpublic Settings onSave()\n\t{\n\t\tsettings.setIncomingDirectory(TextFieldUtils.getString(incomingDirectory));\n\n\t\treturn settings;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/SettingsWindowController.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.common.Features;\nimport io.xeres.ui.client.SettingsClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.model.settings.Settings;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.geometry.Pos;\nimport javafx.scene.Node;\nimport javafx.scene.control.ListView;\nimport javafx.scene.layout.AnchorPane;\nimport javafx.scene.layout.StackPane;\nimport net.rgielen.fxweaver.core.FxWeaver;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\n\n@Component\n@FxmlView(value = \"/view/settings/settings.fxml\")\npublic class SettingsWindowController implements WindowController\n{\n\tprivate static final int PREFERENCE_ICON_SIZE = 24;\n\n\tprivate final SettingsClient settingsClient;\n\n\t@FXML\n\tprivate ListView<SettingsGroup> listView;\n\n\tprivate Settings originalSettings;\n\tprivate Settings newSettings;\n\n\t@FXML\n\tprivate AnchorPane content;\n\n\tprivate final FxWeaver fxWeaver;\n\tprivate final ResourceBundle bundle;\n\n\tpublic SettingsWindowController(SettingsClient settingsClient, FxWeaver fxWeaver, ResourceBundle bundle)\n\t{\n\t\tthis.settingsClient = settingsClient;\n\t\tthis.fxWeaver = fxWeaver;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tlistView.setCellFactory(_ -> new SettingsCell());\n\n\t\tlistView.getItems().addAll(\n\t\t\t\tnew SettingsGroup(bundle.getString(\"settings.general\"), createPreferenceGraphic(\"mdi2c-cog\"), SettingsGeneralController.class),\n\t\t\t\tnew SettingsGroup(bundle.getString(\"settings.notifications\"), createPreferenceGraphic(\"mdi2m-message-alert\"), SettingsNotificationController.class),\n\t\t\t\tnew SettingsGroup(bundle.getString(\"settings.network\"), createPreferenceGraphic(\"mdi2s-server-network\"), SettingsNetworksController.class),\n\t\t\t\tnew SettingsGroup(bundle.getString(\"settings.transfer\"), createPreferenceGraphic(\"mdi2b-briefcase-download\"), SettingsTransferController.class),\n\t\t\t\tnew SettingsGroup(bundle.getString(\"settings.sound\"), createPreferenceGraphic(\"mdi2m-music\"), SettingsSoundController.class),\n\t\t\t\tnew SettingsGroup(bundle.getString(\"settings.remote\"), createPreferenceGraphic(\"mdi2e-earth\"), SettingsRemoteController.class)\n\t\t);\n\n\t\tlistView.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> {\n\t\t\tsaveContent();\n\n\t\t\tcontent.getChildren().clear();\n\t\t\tif (newValue.controllerClass() != null)\n\t\t\t{\n\t\t\t\tvar controllerAndView = fxWeaver.load(newValue.controllerClass(), bundle);\n\t\t\t\tcontrollerAndView.getController().onLoad(newSettings);\n\n\t\t\t\tvar view = controllerAndView.getView().orElseThrow();\n\n\t\t\t\tcontent.getChildren().add(view);\n\t\t\t\tAnchorPane.setTopAnchor(view, 0.0);\n\t\t\t\tAnchorPane.setBottomAnchor(view, 0.0);\n\t\t\t\tAnchorPane.setLeftAnchor(view, 0.0);\n\t\t\t\tAnchorPane.setRightAnchor(view, 0.0);\n\n\t\t\t\tview.setUserData(controllerAndView.getController());\n\t\t\t}\n\t\t});\n\n\t\tlistView.setDisable(true);\n\n\t\tsettingsClient.getSettings().doOnSuccess(settings -> Platform.runLater(() -> {\n\t\t\t\t\tassert settings != null;\n\t\t\t\t\toriginalSettings = settings;\n\t\t\t\t\tnewSettings = originalSettings.clone();\n\t\t\t\t\tlistView.setDisable(false);\n\t\t\t\t\tlistView.getSelectionModel().selectFirst();\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\t@Override\n\tpublic void onHidden()\n\t{\n\t\tsaveContent();\n\n\t\tif (newSettings != null)\n\t\t{\n\t\t\tif (Features.USE_PATCH_SETTINGS)\n\t\t\t{\n\t\t\t\tsettingsClient.patchSettings(originalSettings, newSettings)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsettingsClient.putSettings(newSettings)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void saveContent()\n\t{\n\t\tif (!content.getChildren().isEmpty())\n\t\t{\n\t\t\tvar controller = (SettingsController) content.getChildren().getFirst().getUserData();\n\t\t\tvar controllerSettings = controller.onSave();\n\t\t\tif (controllerSettings != null)\n\t\t\t{\n\t\t\t\tnewSettings = controllerSettings;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static Node createPreferenceGraphic(String iconCode)\n\t{\n\t\tvar pane = new StackPane(new FontIcon(iconCode));\n\t\tpane.setPrefWidth(PREFERENCE_ICON_SIZE);\n\t\tpane.setPrefHeight(PREFERENCE_ICON_SIZE);\n\t\tpane.setAlignment(Pos.CENTER);\n\t\treturn pane;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/settings/ThemeCell.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.settings;\n\nimport io.xeres.ui.support.theme.AppTheme;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport javafx.scene.Node;\nimport javafx.scene.control.ListCell;\nimport javafx.scene.image.ImageView;\n\nclass ThemeCell extends ListCell<AppTheme>\n{\n\tprivate final Node parent;\n\n\tpublic ThemeCell(Node parent)\n\t{\n\t\tthis.parent = parent;\n\t}\n\n\t@Override\n\tprotected void updateItem(AppTheme appTheme, boolean empty)\n\t{\n\t\tsuper.updateItem(appTheme, empty);\n\n\t\tif (!empty)\n\t\t{\n\t\t\tvar imageView = new ImageView(\"/image/themes/\" + appTheme.getName() + \".png\");\n\t\t\tImageViewUtils.disableOutputScaling(imageView, parent);\n\t\t\tsetText(appTheme.getName());\n\t\t\tsetGraphic(imageView);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetText(null);\n\t\t\tsetGraphic(null);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/share/ShareWindowController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.share;\n\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.client.ShareClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.model.share.Share;\nimport io.xeres.ui.support.contextmenu.XContextMenu;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.property.SimpleStringProperty;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.*;\nimport javafx.scene.control.cell.CheckBoxTableCell;\nimport javafx.scene.control.cell.ChoiceBoxTableCell;\nimport javafx.scene.control.cell.TextFieldTableCell;\nimport javafx.stage.DirectoryChooser;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignF;\nimport org.springframework.stereotype.Component;\n\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.HashSet;\nimport java.util.ResourceBundle;\nimport java.util.Set;\n\nimport static io.xeres.common.dto.share.ShareConstants.INCOMING_SHARE;\nimport static javafx.scene.control.Alert.AlertType.INFORMATION;\nimport static javafx.scene.control.TableColumn.SortType.ASCENDING;\nimport static org.apache.commons.lang3.StringUtils.isBlank;\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\n\n@Component\n@FxmlView(value = \"/view/file/share.fxml\")\npublic class ShareWindowController implements WindowController\n{\n\tprivate static final String REMOVE_MENU_ID = \"remove\";\n\tprivate static final String SHOW_IN_FOLDER_MENU_ID = \"showInFolder\";\n\n\tprivate final ShareClient shareClient;\n\n\tprivate final ResourceBundle bundle;\n\n\t@FXML\n\tprivate TableView<Share> shareTableView;\n\n\t@FXML\n\tprivate TableColumn<Share, String> tableDirectory; // XXX: or path?\n\n\t@FXML\n\tprivate TableColumn<Share, String> tableName;\n\n\t@FXML\n\tprivate TableColumn<Share, Boolean> tableSearchable;\n\n\t@FXML\n\tprivate TableColumn<Share, Trust> tableBrowsable;\n\n\t@FXML\n\tprivate Button applyButton;\n\n\t@FXML\n\tprivate Button addButton;\n\n\t@FXML\n\tprivate Button cancelButton;\n\n\tprivate boolean refreshHack;\n\n\tpublic ShareWindowController(ShareClient shareClient, ResourceBundle bundle)\n\t{\n\t\tthis.shareClient = shareClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tcreateShareTableViewContextMenu();\n\n\t\ttableDirectory.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().getPath()));\n\t\ttableDirectory.setOnEditStart(param -> {\n\t\t\tif (refreshHack)\n\t\t\t{\n\t\t\t\trefreshHack = false;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (RemoteUtils.isRemoteUiClient())\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(INFORMATION, bundle.getString(\"settings.directory.no-remote\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar directoryChooser = new DirectoryChooser();\n\t\t\tdirectoryChooser.setTitle(bundle.getString(\"share.select-directory\"));\n\t\t\tif (!isEmpty(param.getOldValue()))\n\t\t\t{\n\t\t\t\tvar previousPath = Path.of(param.getOldValue());\n\t\t\t\tChooserUtils.setInitialDirectory(directoryChooser, previousPath);\n\t\t\t}\n\t\t\tvar selectedDirectory = directoryChooser.showDialog(UiUtils.getWindow(shareTableView));\n\t\t\tif (selectedDirectory != null && selectedDirectory.isDirectory())\n\t\t\t{\n\t\t\t\tgetCurrentItem(param).setPath(selectedDirectory.getPath());\n\t\t\t\trefreshHack = true; // refresh() calls setOnEditStart() again so we need that workaround\n\t\t\t\tparam.getTableView().refresh();\n\n\t\t\t}\n\n\t\t\t// We clear the selection so that the directory selector can be triggered again. Go figure...\n\t\t\tPlatform.runLater(param.getTableView().getSelectionModel()::clearSelection);\n\t\t});\n\t\ttableDirectory.setOnEditCommit(param -> getCurrentItem(param).setPath(param.getNewValue()));\n\n\t\ttableName.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().getName()));\n\t\ttableName.setCellFactory(TextFieldTableCell.forTableColumn());\n\t\ttableName.setOnEditCommit(param -> getCurrentItem(param).setName(param.getNewValue()));\n\t\t// XXX: when clicking outside tableName, the value isn't committed but the edited value stays on display anyway (which is wrong). but we get no event at all\n\n\t\t// setOnEditCommit() doesn't work for CheckBoxes, so we have to do that\n\t\ttableSearchable.setCellFactory(_ -> new CheckBoxTableCell<>());\n\t\ttableSearchable.setCellValueFactory(param -> param.getValue().searchableProperty());\n\n\t\ttableBrowsable.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getBrowsable()));\n\t\ttableBrowsable.setCellFactory(ChoiceBoxTableCell.forTableColumn(new TrustConverter(), Trust.values()));\n\t\ttableBrowsable.setOnEditCommit(param -> getCurrentItem(param).setBrowsable(param.getNewValue()));\n\n\t\taddButton.setOnAction(_ -> {\n\t\t\tvar downloadPath = OsUtils.getDownloadDir();\n\t\t\tvar newShare = new Share();\n\t\t\tnewShare.setName(downloadPath.getName(downloadPath.getNameCount() - 1).toString());\n\t\t\tnewShare.setPath(downloadPath.toString());\n\t\t\tnewShare.setSearchable(true);\n\t\t\tnewShare.setBrowsable(Trust.NEVER);\n\t\t\tshareTableView.getItems().add(newShare);\n\t\t\tshareTableView.getSelectionModel().select(newShare);\n\t\t\tshareTableView.edit(shareTableView.getSelectionModel().getSelectedIndex(), tableName);\n\t\t});\n\n\t\tapplyButton.setOnAction(event -> Platform.runLater(() -> {\n\t\t\tif (validateShares())\n\t\t\t{\n\t\t\t\tshareClient.createAndUpdate(shareTableView.getItems())\n\t\t\t\t\t\t.doOnSuccess(_ -> Platform.runLater(() -> UiUtils.closeWindow(event)))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.subscribe();\n\t\t\t}\n\t\t}));\n\n\t\tcancelButton.setOnAction(UiUtils::closeWindow);\n\n\t\tshareClient.findAll().collectList()\n\t\t\t\t.doOnSuccess(shares -> Platform.runLater(() -> {\n\t\t\t\t\tassert shares != null;\n\t\t\t\t\t// Add all shares\n\t\t\t\t\tshareTableView.getItems().addAll(shares);\n\n\t\t\t\t\t// Sort by visible name\n\t\t\t\t\tshareTableView.getSortOrder().add(tableName);\n\t\t\t\t\ttableName.setSortType(ASCENDING);\n\t\t\t\t\ttableName.setSortable(true);\n\t\t\t\t}))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void createShareTableViewContextMenu()\n\t{\n\t\tvar removeItem = new MenuItem(bundle.getString(\"share.remove\"));\n\t\tremoveItem.setId(REMOVE_MENU_ID);\n\t\tremoveItem.setGraphic(new FontIcon(MaterialDesignF.FOLDER_REMOVE));\n\t\tremoveItem.setOnAction(event -> {\n\t\t\tvar share = (Share) event.getSource();\n\t\t\tshareTableView.getItems().remove(share);\n\t\t});\n\n\t\tvar showInExplorerItem = new MenuItem(bundle.getString(\"download-view.show-in-folder\"));\n\t\tshowInExplorerItem.setId(SHOW_IN_FOLDER_MENU_ID);\n\t\tshowInExplorerItem.setGraphic(new FontIcon(MaterialDesignF.FOLDER_OPEN));\n\t\tshowInExplorerItem.setOnAction(event -> {\n\t\t\tif (event.getSource() instanceof Share share)\n\t\t\t{\n\t\t\t\tOsUtils.showFolder(Paths.get(share.getPath()).toFile());\n\t\t\t}\n\t\t});\n\n\t\tvar xContextMenu = new XContextMenu<Share>(showInExplorerItem, new SeparatorMenuItem(), removeItem);\n\t\txContextMenu.addToNode(shareTableView);\n\t\txContextMenu.setOnShowing((contextMenu, share) -> {\n\t\t\tcontextMenu.getItems().stream()\n\t\t\t\t\t.filter(menuItem -> REMOVE_MENU_ID.equals(menuItem.getId()))\n\t\t\t\t\t.findFirst().ifPresent(menuItem -> menuItem.setDisable(share.getId() == INCOMING_SHARE));\n\t\t\treturn share != null;\n\t\t});\n\t}\n\n\tprivate boolean validateShares()\n\t{\n\t\tSet<String> shareNames = HashSet.newHashSet(shareTableView.getItems().size());\n\n\t\tfor (var share : shareTableView.getItems())\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif (isBlank(share.getName()))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(bundle.getString(\"share.error.empty-name\"));\n\t\t\t\t}\n\t\t\t\tif (isBlank(share.getPath()))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(bundle.getString(\"share.error.empty-path\"));\n\t\t\t\t}\n\t\t\t\tif (shareNames.contains(share.getName()))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(bundle.getString(\"share.error.not-unique\"));\n\t\t\t\t}\n\t\t\t\tshareNames.add(share.getName());\n\t\t\t}\n\t\t\tcatch (IllegalArgumentException e)\n\t\t\t{\n\t\t\t\tshareTableView.getSelectionModel().select(share);\n\t\t\t\tUiUtils.webAlertError(e);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\tprivate static <T> T getCurrentItem(TableColumn.CellEditEvent<T, ?> param)\n\t{\n\t\treturn param.getTableView().getItems().get(param.getTablePosition().getRow());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/share/TrustConverter.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.share;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.pgp.Trust;\nimport javafx.util.StringConverter;\n\nimport java.util.Arrays;\nimport java.util.ResourceBundle;\n\npublic class TrustConverter extends StringConverter<Trust>\n{\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tprivate enum Permission\n\t{\n\t\tNOBODY(Trust.UNKNOWN, bundle.getString(\"trust-converter.nobody\")),\n\t\tANYBODY(Trust.NEVER, bundle.getString(\"trust-converter.everybody\")),\n\t\tMARGINAL(Trust.MARGINAL, bundle.getString(\"trust-converter.marginal\")),\n\t\tFULL(Trust.FULL, bundle.getString(\"trust-converter.full\")),\n\t\tULTIMATE(Trust.ULTIMATE, bundle.getString(\"trust-converter.ultimate\"));\n\n\t\tprivate final Trust trust;\n\t\tprivate final String value;\n\n\t\tPermission(Trust trust, String value)\n\t\t{\n\t\t\tthis.trust = trust;\n\t\t\tthis.value = value;\n\t\t}\n\n\t\tpublic Trust getTrust()\n\t\t{\n\t\t\treturn trust;\n\t\t}\n\n\t\tpublic String getValue()\n\t\t{\n\t\t\treturn value;\n\t\t}\n\t}\n\n\t@Override\n\tpublic String toString(Trust trust)\n\t{\n\t\treturn Arrays.stream(Permission.values())\n\t\t\t\t.filter(permission -> permission.getTrust() == trust)\n\t\t\t\t.findFirst().orElseThrow()\n\t\t\t\t.getValue();\n\t}\n\n\t@Override\n\tpublic Trust fromString(String s)\n\t{\n\t\treturn Arrays.stream(Permission.values())\n\t\t\t\t.filter(permission -> permission.getValue().equals(s))\n\t\t\t\t.findFirst().orElseThrow()\n\t\t\t\t.getTrust();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsDataCounterController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.statistics;\n\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.ui.client.StatisticsClient;\nimport io.xeres.ui.controller.Controller;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.chart.BarChart;\nimport javafx.scene.chart.CategoryAxis;\nimport javafx.scene.chart.XYChart;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.ResourceBundle;\nimport java.util.concurrent.ScheduledExecutorService;\n\n@Component\n@FxmlView(value = \"/view/statistics/datacounter.fxml\")\npublic class StatisticsDataCounterController implements Controller\n{\n\tpublic static final int UPDATE_IN_SECONDS = 10;\n\n\t@FXML\n\tprivate BarChart<String, Number> barChart;\n\n\t@FXML\n\tprivate CategoryAxis xAxis;\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate final XYChart.Series<String, Number> in = new XYChart.Series<>();\n\tprivate final XYChart.Series<String, Number> out = new XYChart.Series<>();\n\n\tprivate final StatisticsClient statisticsClient;\n\tprivate final ResourceBundle bundle;\n\n\tpublic StatisticsDataCounterController(StatisticsClient statisticsClient, ResourceBundle bundle)\n\t{\n\t\tthis.statisticsClient = statisticsClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\tin.setName(bundle.getString(\"statistics.turtle.data-in\"));\n\t\tout.setName(bundle.getString(\"statistics.turtle.data-out\"));\n\t\t//noinspection unchecked\n\t\tbarChart.getData().addAll(in, out);\n\t}\n\n\tpublic void start()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(() -> statisticsClient.getDataCounterStatistics()\n\t\t\t\t\t\t.doOnSuccess(dataCounterStatisticsResponse -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert dataCounterStatisticsResponse != null;\n\t\t\t\t\t\t\tin.getData().clear();\n\t\t\t\t\t\t\tout.getData().clear();\n\n\t\t\t\t\t\t\tdataCounterStatisticsResponse.peers().forEach(dataPeer -> {\n\t\t\t\t\t\t\t\tin.getData().add(new XYChart.Data<>(dataPeer.name(), dataPeer.received() / 1024));\n\t\t\t\t\t\t\t\tout.getData().add(new XYChart.Data<>(dataPeer.name(), dataPeer.sent() / 1024));\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe(),\n\t\t\t\t1,\n\t\t\t\tUPDATE_IN_SECONDS);\n\t}\n\n\tpublic void stop()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t\tbarChart.getData().clear();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsMainWindowController.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.statistics;\n\nimport io.xeres.ui.controller.WindowController;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.TabPane;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\n@Component\n@FxmlView(value = \"/view/statistics/main.fxml\")\npublic class StatisticsMainWindowController implements WindowController\n{\n\t@FXML\n\tprivate TabPane tabPane;\n\n\t// This field name to get the controller is some black magic, see last answer at https://stackoverflow.com/questions/40754454/get-controller-instance-from-node\n\t@FXML\n\tprivate StatisticsTurtleController statisticsTurtleController;\n\n\t@FXML\n\tprivate StatisticsRttController statisticsRttController;\n\n\t@FXML\n\tprivate StatisticsDataCounterController statisticsDataCounterController;\n\n\t@Override\n\tpublic void initialize()\n\t{\n\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tstatisticsTurtleController.start();\n\t\tstatisticsRttController.start();\n\t\tstatisticsDataCounterController.start();\n\t}\n\n\t@Override\n\tpublic void onHiding()\n\t{\n\t\tstatisticsTurtleController.stop();\n\t\tstatisticsRttController.stop();\n\t\tstatisticsDataCounterController.stop();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsRttController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.statistics;\n\nimport io.xeres.common.rest.statistics.RttPeer;\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.ui.client.StatisticsClient;\nimport io.xeres.ui.controller.Controller;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.chart.LineChart;\nimport javafx.scene.chart.NumberAxis;\nimport javafx.scene.chart.XYChart;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.ResourceBundle;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.stream.Collectors;\n\n@Component\n@FxmlView(value = \"/view/statistics/rtt.fxml\")\npublic class StatisticsRttController implements Controller\n{\n\tprivate static final int UPDATE_IN_SECONDS = 10;\n\tprivate static final int DATA_WINDOW_SIZE = 12; // 2 minutes of data (one each 10 seconds)\n\n\t@FXML\n\tprivate LineChart<Number, Number> lineChart;\n\n\t@FXML\n\tprivate NumberAxis xAxis;\n\n\tprivate final Map<Long, XYChart.Series<Number, Number>> peerSeries = new HashMap<>();\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate final StatisticsClient statisticsClient;\n\n\tprivate final ResourceBundle bundle;\n\n\tpublic StatisticsRttController(StatisticsClient statisticsClient, ResourceBundle bundle)\n\t{\n\t\tthis.statisticsClient = statisticsClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\txAxis.setTickLabelFormatter(new NumberAxis.DefaultFormatter(xAxis)\n\t\t{\n\t\t\t@Override\n\t\t\tpublic String toString(Number object)\n\t\t\t{\n\t\t\t\treturn String.valueOf(-object.intValue());\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void start()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(() -> statisticsClient.getRttStatistics()\n\t\t\t\t\t\t.doOnSuccess(rttStatisticsResponse -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert rttStatisticsResponse != null;\n\t\t\t\t\t\t\trttStatisticsResponse.peers().forEach(rttPeer -> {\n\t\t\t\t\t\t\t\tvar series = peerSeries.computeIfAbsent(rttPeer.id(), _ -> createSeries(rttPeer));\n\t\t\t\t\t\t\t\tupdateData(series, rttPeer.mean());\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\tvar ids = rttStatisticsResponse.peers().stream()\n\t\t\t\t\t\t\t\t\t.map(RttPeer::id)\n\t\t\t\t\t\t\t\t\t.collect(Collectors.toSet());\n\n\t\t\t\t\t\t\tpeerSeries.entrySet().removeIf(entry -> {\n\t\t\t\t\t\t\t\tif (!ids.contains(entry.getKey()))\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tlineChart.getData().remove(entry.getValue());\n\t\t\t\t\t\t\t\t\treturn true;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn false;\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe(),\n\t\t\t\t0,\n\t\t\t\tUPDATE_IN_SECONDS); // XXX: that period should be shared somewhere\n\t}\n\n\tpublic void stop()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\t\tpeerSeries.clear();\n\t}\n\n\tprivate XYChart.Series<Number, Number> createSeries(RttPeer rttPeer)\n\t{\n\t\tvar series = new XYChart.Series<Number, Number>();\n\t\tseries.setName(rttPeer.name());\n\t\tlineChart.getData().add(series);\n\t\treturn series;\n\t}\n\n\tprivate static void updateData(XYChart.Series<Number, Number> series, float value)\n\t{\n\t\tseries.getData().forEach(numberNumberData -> numberNumberData.setXValue(numberNumberData.getXValue().intValue() - UPDATE_IN_SECONDS));\n\t\tseries.getData().addFirst(new XYChart.Data<>(0, value));\n\t\tif (series.getData().size() > DATA_WINDOW_SIZE + 1)\n\t\t{\n\t\t\tseries.getData().removeLast();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/statistics/StatisticsTurtleController.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.statistics;\n\nimport io.xeres.common.util.ExecutorUtils;\nimport io.xeres.ui.client.StatisticsClient;\nimport io.xeres.ui.controller.Controller;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.fxml.FXML;\nimport javafx.scene.Cursor;\nimport javafx.scene.chart.LineChart;\nimport javafx.scene.chart.NumberAxis;\nimport javafx.scene.chart.XYChart;\nimport javafx.scene.control.Label;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\n\nimport java.util.Map;\nimport java.util.ResourceBundle;\nimport java.util.concurrent.ScheduledExecutorService;\n\n@Component\n@FxmlView(value = \"/view/statistics/turtle.fxml\")\npublic class StatisticsTurtleController implements Controller\n{\n\tprivate static final int UPDATE_IN_SECONDS = 2;\n\tprivate static final int DATA_WINDOW_SIZE = 60; // 2 minutes of data (one data each 2 seconds)\n\n\t@FXML\n\tprivate LineChart<Number, Number> lineChart;\n\n\t@FXML\n\tprivate NumberAxis xAxis;\n\n\tprivate ScheduledExecutorService executorService;\n\n\tprivate final StatisticsClient statisticsClient;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate final XYChart.Series<Number, Number> dataDownload = new XYChart.Series<>();\n\tprivate final XYChart.Series<Number, Number> dataUpload = new XYChart.Series<>();\n\tprivate final XYChart.Series<Number, Number> forwardTotal = new XYChart.Series<>();\n\tprivate final XYChart.Series<Number, Number> tunnelRequestsDownload = new XYChart.Series<>();\n\tprivate final XYChart.Series<Number, Number> tunnelRequestsUpload = new XYChart.Series<>();\n\tprivate final XYChart.Series<Number, Number> searchRequestsDownload = new XYChart.Series<>();\n\tprivate final XYChart.Series<Number, Number> searchRequestsUpload = new XYChart.Series<>();\n\n\tpublic StatisticsTurtleController(StatisticsClient statisticsClient, ResourceBundle bundle)\n\t{\n\t\tthis.statisticsClient = statisticsClient;\n\t\tthis.bundle = bundle;\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\txAxis.setTickLabelFormatter(new NumberAxis.DefaultFormatter(xAxis)\n\t\t{\n\t\t\t@Override\n\t\t\tpublic String toString(Number object)\n\t\t\t{\n\t\t\t\treturn String.valueOf(-object.intValue());\n\t\t\t}\n\t\t});\n\n\t\tdataDownload.setName(bundle.getString(\"statistics.turtle.data-in\"));\n\t\tdataUpload.setName(bundle.getString(\"statistics.turtle.data-out\"));\n\t\tforwardTotal.setName(bundle.getString(\"statistics.turtle.data-forward\"));\n\t\ttunnelRequestsDownload.setName(bundle.getString(\"statistics.turtle.tunnel-in\"));\n\t\ttunnelRequestsUpload.setName(bundle.getString(\"statistics.turtle.tunnel-out\"));\n\t\tsearchRequestsDownload.setName(bundle.getString(\"statistics.turtle.search-in\"));\n\t\tsearchRequestsUpload.setName(bundle.getString(\"statistics.turtle.search-out\"));\n\n\t\tlineChart.getData().add(dataDownload);\n\t\tlineChart.getData().add(dataUpload);\n\t\tlineChart.getData().add(forwardTotal);\n\t\tlineChart.getData().add(tunnelRequestsDownload);\n\t\tlineChart.getData().add(tunnelRequestsUpload);\n\t\tlineChart.getData().add(searchRequestsDownload);\n\t\tlineChart.getData().add(searchRequestsUpload);\n\n\t\tvar legendTips = Map.of(\n\t\t\t\tdataDownload.getName(), bundle.getString(\"statistics.turtle.data-in.tip\"),\n\t\t\t\tdataUpload.getName(), bundle.getString(\"statistics.turtle.data-out.tip\"),\n\t\t\t\tforwardTotal.getName(), bundle.getString(\"statistics.turtle.data-forward.tip\"),\n\t\t\t\ttunnelRequestsDownload.getName(), bundle.getString(\"statistics.turtle.tunnel-in.tip\"),\n\t\t\t\ttunnelRequestsUpload.getName(), bundle.getString(\"statistics.turtle.tunnel-out.tip\"),\n\t\t\t\tsearchRequestsDownload.getName(), bundle.getString(\"statistics.turtle.search-in.tip\"),\n\t\t\t\tsearchRequestsUpload.getName(), bundle.getString(\"statistics.turtle.search-out.tip\")\n\t\t);\n\n\t\tlineChart.lookupAll(\"Label.chart-legend-item\").forEach(node -> {\n\t\t\tif (node instanceof Label label)\n\t\t\t{\n\t\t\t\tlabel.setCursor(Cursor.HAND);\n\t\t\t\tTooltipUtils.install(label, legendTips.get(label.getText()));\n\t\t\t\tUiUtils.setOnPrimaryMouseClicked(label, _ -> {\n\t\t\t\t\tlabel.setOpacity(label.getOpacity() > 0.75 ? 0.5 : 1.0);\n\t\t\t\t\tlineChart.getData().forEach(series -> {\n\t\t\t\t\t\tif (series.getName().equals(label.getText()))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tseries.getNode().setVisible(!series.getNode().isVisible());\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void start()\n\t{\n\t\texecutorService = ExecutorUtils.createFixedRateExecutor(() -> statisticsClient.getTurtleStatistics()\n\t\t\t\t\t\t.doOnSuccess(turtleStatisticsResponse -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tassert turtleStatisticsResponse != null;\n\t\t\t\t\t\t\tupdateData(dataDownload, turtleStatisticsResponse.dataDownload() / 1024f);\n\t\t\t\t\t\t\tupdateData(dataUpload, turtleStatisticsResponse.dataUpload() / 1024f);\n\t\t\t\t\t\t\tupdateData(forwardTotal, turtleStatisticsResponse.forwardTotal() / 1024f);\n\t\t\t\t\t\t\tupdateData(tunnelRequestsDownload, turtleStatisticsResponse.tunnelRequestsDownload() / 1024f);\n\t\t\t\t\t\t\tupdateData(tunnelRequestsUpload, turtleStatisticsResponse.tunnelRequestsUpload() / 1024f);\n\t\t\t\t\t\t\tupdateData(searchRequestsDownload, turtleStatisticsResponse.searchRequestsDownload() / 1024f);\n\t\t\t\t\t\t\tupdateData(searchRequestsUpload, turtleStatisticsResponse.searchRequestsUpload() / 1024f);\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.subscribe(),\n\t\t\t\t0,\n\t\t\t\tUPDATE_IN_SECONDS); // XXX: that period should be shared somewhere\n\t}\n\n\tpublic void stop()\n\t{\n\t\tExecutorUtils.cleanupExecutor(executorService);\n\n\t\tdataDownload.getData().clear();\n\t\tdataUpload.getData().clear();\n\t\tforwardTotal.getData().clear();\n\t\ttunnelRequestsDownload.getData().clear();\n\t\ttunnelRequestsUpload.getData().clear();\n\t\tsearchRequestsDownload.getData().clear();\n\t\tsearchRequestsUpload.getData().clear();\n\t}\n\n\tprivate static void updateData(XYChart.Series<Number, Number> series, float value)\n\t{\n\t\tseries.getData().forEach(numberNumberData -> numberNumberData.setXValue(numberNumberData.getXValue().intValue() - UPDATE_IN_SECONDS));\n\t\tseries.getData().addFirst(new XYChart.Data<>(0, value));\n\t\tif (series.getData().size() > DATA_WINDOW_SIZE + 1)\n\t\t{\n\t\t\tseries.getData().removeLast();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/voip/TimeCounter.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.voip;\n\nimport javafx.animation.Animation;\nimport javafx.animation.KeyFrame;\nimport javafx.animation.Timeline;\nimport javafx.util.Duration;\n\nimport java.time.Instant;\nimport java.util.function.Consumer;\n\npublic class TimeCounter\n{\n\tprivate Instant startTime;\n\tprivate Timeline timeline;\n\tprivate final Consumer<java.time.Duration> consumer;\n\n\tpublic TimeCounter(Consumer<java.time.Duration> consumer)\n\t{\n\t\tthis.consumer = consumer;\n\t}\n\n\tpublic void start()\n\t{\n\t\tstop();\n\t\tstartTime = Instant.now();\n\t\ttimeline = new Timeline(\n\t\t\t\tnew KeyFrame(Duration.ZERO, _ -> update()),\n\t\t\t\tnew KeyFrame(Duration.seconds(1))\n\t\t);\n\t\ttimeline.setCycleCount(Animation.INDEFINITE);\n\t\ttimeline.play();\n\t}\n\n\tprivate void update()\n\t{\n\t\tvar now = Instant.now();\n\t\tvar duration = java.time.Duration.between(startTime, now);\n\t\tconsumer.accept(duration);\n\t}\n\n\tpublic void stop()\n\t{\n\t\tif (timeline != null)\n\t\t{\n\t\t\ttimeline.stop();\n\t\t\tupdate();\n\t\t\ttimeline = null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/controller/voip/VoipWindowController.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.voip;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.message.voip.VoipAction;\nimport io.xeres.common.message.voip.VoipMessage;\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.client.ProfileClient;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.support.contact.ContactUtils;\nimport io.xeres.ui.support.sound.SoundPlayerService;\nimport io.xeres.ui.support.util.DateUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport javafx.beans.binding.BooleanBinding;\nimport javafx.fxml.FXML;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.Label;\nimport javafx.scene.media.AudioClip;\nimport net.rgielen.fxweaver.core.FxmlView;\nimport org.springframework.stereotype.Component;\nimport reactor.core.scheduler.Schedulers;\n\nimport java.time.LocalTime;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.ui.support.sound.SoundPlayerService.SoundType;\n\n@Component\n@FxmlView(value = \"/view/voip/voip.fxml\")\npublic class VoipWindowController implements WindowController\n{\n\tpublic record Parameters(String destination, VoipMessage message)\n\t{\n\t}\n\n\tprivate enum Status\n\t{\n\t\tINCOMING_CALL,\n\t\tOUTGOING_CALL,\n\t\tIN_CALL,\n\t\tENDED\n\t}\n\n\t@FXML\n\tprivate AsyncImageView imageView;\n\n\t@FXML\n\tprivate Label nameLabel;\n\n\t@FXML\n\tprivate Label statusLabel;\n\n\t@FXML\n\tprivate Label timerLabel;\n\n\t@FXML\n\tprivate Button messageButton;\n\n\t@FXML\n\tprivate Button recallButton;\n\n\t@FXML\n\tprivate Button closeButton;\n\n\t@FXML\n\tprivate Button answerButton;\n\n\t@FXML\n\tprivate Button rejectButton;\n\n\tprivate final MessageClient messageClient;\n\tprivate final GeneralClient generalClient;\n\tprivate final ProfileClient profileClient;\n\tprivate final WindowManager windowManager;\n\tprivate final ResourceBundle bundle;\n\tprivate final SoundPlayerService soundPlayerService;\n\n\tprivate LocationIdentifier destinationIdentifier;\n\tprivate Status status;\n\tprivate final TimeCounter timeCounter;\n\tprivate AudioClip audioClip;\n\n\tpublic VoipWindowController(MessageClient messageClient, GeneralClient generalClient, ProfileClient profileClient, WindowManager windowManager, ResourceBundle bundle, SoundPlayerService soundPlayerService)\n\t{\n\t\tthis.messageClient = messageClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.profileClient = profileClient;\n\t\tthis.windowManager = windowManager;\n\t\tthis.bundle = bundle;\n\t\tthis.soundPlayerService = soundPlayerService;\n\n\t\ttimeCounter = new TimeCounter(duration -> timerLabel.setText(DateUtils.TIME_PRECISE_FORMAT.format(LocalTime.ofSecondOfDay(duration.getSeconds() % (24 * 3600)))));\n\t}\n\n\t@Override\n\tpublic void initialize()\n\t{\n\t\timageView.setLoader(url -> generalClient.getImage(url).block());\n\n\t\tanswerButton.setOnAction(_ -> {\n\t\t\tmessageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.ACKNOWLEDGE));\n\t\t\tstatus = Status.IN_CALL;\n\t\t\tupdateState();\n\t\t});\n\t\trejectButton.setOnAction(_ -> {\n\t\t\tmessageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.CLOSE));\n\t\t\tstatus = Status.ENDED;\n\t\t\tupdateState();\n\t\t});\n\t\tmessageButton.setOnAction(_ -> windowManager.openMessaging(destinationIdentifier));\n\t\trecallButton.setOnAction(_ -> {\n\t\t\tmessageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.RING));\n\t\t\tstatus = Status.OUTGOING_CALL;\n\t\t\tupdateState();\n\t\t});\n\t\tcloseButton.setOnAction(_ -> UiUtils.getWindow(nameLabel).hide());\n\t}\n\n\t@Override\n\tpublic void onShown()\n\t{\n\t\tvar userData = (Parameters) Objects.requireNonNull(UiUtils.getUserData(answerButton), \"missing Parameters userdata\");\n\t\tdestinationIdentifier = LocationIdentifier.fromString(userData.destination);\n\t\tprofileClient.findByLocationIdentifier(destinationIdentifier, false).collectList()\n\t\t\t\t.publishOn(Schedulers.boundedElastic())\n\t\t\t\t.doOnSuccess(profiles -> {\n\t\t\t\t\tassert profiles != null;\n\t\t\t\t\tprofileClient.findContactsForProfile(profiles.getFirst().getId()).collectList()\n\t\t\t\t\t\t\t.doOnSuccess(contacts -> Platform.runLater(() -> {\n\t\t\t\t\t\t\t\tassert contacts != null;\n\t\t\t\t\t\t\t\tif (contacts.isEmpty())\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tnameLabel.setText(profiles.getFirst().getName());\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tvar contact = contacts.getFirst();\n\t\t\t\t\t\t\t\t\tnameLabel.setText(contact.name());\n\t\t\t\t\t\t\t\t\timageView.setUrl(ContactUtils.getIdentityImageUrl(contact));\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\n\t\tif (userData.message == null)\n\t\t{\n\t\t\tmessageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.RING));\n\t\t\tstatus = Status.OUTGOING_CALL;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tstatus = Status.INCOMING_CALL;\n\t\t}\n\t\tupdateState();\n\t\tsetupImagePresence();\n\n\t\tUiUtils.getWindow(nameLabel).setOnCloseRequest(event -> {\n\t\t\tif (status != Status.ENDED)\n\t\t\t{\n\t\t\t\tUiUtils.showAlertConfirm(bundle.getString(\"voip.action.window-quit\"), () -> {\n\t\t\t\t\tstopRingingSound();\n\t\t\t\t\tmessageClient.sendToDestination(destinationIdentifier, new VoipMessage(VoipAction.CLOSE));\n\t\t\t\t\tUiUtils.getWindow(nameLabel).hide();\n\t\t\t\t});\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void doAction(String identifier, VoipMessage voipMessage)\n\t{\n\t\tif (voipMessage == null)\n\t\t{\n\t\t\t// We ignore outgoing calls if the window is already open\n\t\t\tUiUtils.getWindow(nameLabel).requestFocus();\n\t\t\treturn;\n\t\t}\n\t\tdestinationIdentifier = LocationIdentifier.fromString(identifier);\n\t\tswitch (voipMessage.getAction())\n\t\t{\n\t\t\tcase RING -> status = Status.INCOMING_CALL;\n\t\t\tcase ACKNOWLEDGE -> status = Status.IN_CALL;\n\t\t\tcase CLOSE -> status = Status.ENDED;\n\t\t}\n\t\tupdateState();\n\t}\n\n\tprivate void updateState()\n\t{\n\t\tswitch (status)\n\t\t{\n\t\t\tcase INCOMING_CALL ->\n\t\t\t{\n\t\t\t\tstatusLabel.setText(bundle.getString(\"voip.status.incoming\"));\n\t\t\t\trejectButton.setText(bundle.getString(\"voip.action.reject\"));\n\t\t\t\ttimerLabel.setVisible(false);\n\t\t\t\tUiUtils.setAbsent(messageButton);\n\t\t\t\tUiUtils.setAbsent(recallButton);\n\t\t\t\tUiUtils.setAbsent(closeButton);\n\t\t\t\tUiUtils.setPresent(answerButton);\n\t\t\t\tUiUtils.setPresent(rejectButton);\n\t\t\t\tplayRingingSound();\n\t\t\t}\n\t\t\tcase OUTGOING_CALL ->\n\t\t\t{\n\t\t\t\tstatusLabel.setText(bundle.getString(\"voip.status.calling\"));\n\t\t\t\ttimerLabel.setVisible(false);\n\t\t\t\tUiUtils.setAbsent(messageButton);\n\t\t\t\tUiUtils.setAbsent(recallButton);\n\t\t\t\tUiUtils.setAbsent(closeButton);\n\t\t\t\tUiUtils.setAbsent(answerButton);\n\t\t\t\tUiUtils.setPresent(rejectButton);\n\t\t\t\tplayRingingSound();\n\t\t\t}\n\t\t\tcase IN_CALL ->\n\t\t\t{\n\t\t\t\tstatusLabel.setText(bundle.getString(\"voip.status.ongoing\"));\n\t\t\t\ttimeCounter.start();\n\t\t\t\trejectButton.setText(bundle.getString(\"voip.action.hangup\"));\n\t\t\t\ttimerLabel.setVisible(true);\n\t\t\t\tUiUtils.setAbsent(answerButton);\n\t\t\t\tUiUtils.setPresent(rejectButton);\n\t\t\t\tstopRingingSound();\n\t\t\t}\n\t\t\tcase ENDED ->\n\t\t\t{\n\t\t\t\tstatusLabel.setText(bundle.getString(\"voip.status.ended\"));\n\t\t\t\ttimeCounter.stop();\n\t\t\t\tUiUtils.setPresent(messageButton);\n\t\t\t\tUiUtils.setPresent(recallButton);\n\t\t\t\tUiUtils.setPresent(closeButton);\n\t\t\t\tUiUtils.setAbsent(answerButton);\n\t\t\t\tUiUtils.setAbsent(rejectButton);\n\t\t\t\tstopRingingSound();\n\t\t\t}\n\t\t}\n\t}\n\n\t// Remove the contact image when the window is resized small\n\t// so that we can save up space.\n\tprivate void setupImagePresence()\n\t{\n\t\tvar scene = imageView.getScene();\n\n\t\tBooleanBinding showImage = scene.widthProperty().greaterThan(300)\n\t\t\t\t.and(scene.heightProperty().greaterThan(280));\n\n\t\timageView.managedProperty().bind(showImage);\n\t\timageView.visibleProperty().bind(showImage);\n\t}\n\n\tprivate void playRingingSound()\n\t{\n\t\tstopRingingSound();\n\t\taudioClip = soundPlayerService.playRepeated(SoundType.RINGING);\n\t}\n\n\tprivate void stopRingingSound()\n\t{\n\t\tif (audioClip != null)\n\t\t{\n\t\t\taudioClip.stop();\n\t\t\taudioClip = null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/DelayedAction.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport javafx.animation.KeyFrame;\nimport javafx.animation.Timeline;\nimport javafx.application.Platform;\n\nimport java.time.Duration;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n * Class to run a delayed action. Set a start runnable, a stop runnable and delay to run the start runnable.\n */\npublic class DelayedAction\n{\n\tprivate final AtomicBoolean shouldRun = new AtomicBoolean();\n\tprivate Timeline timeline;\n\tprivate final javafx.util.Duration delay;\n\tprivate final Runnable start;\n\tprivate final Runnable stop;\n\n\tpublic DelayedAction(Runnable start, Runnable stop, Duration delay)\n\t{\n\t\tthis.start = start;\n\t\tthis.stop = stop;\n\t\tthis.delay = javafx.util.Duration.millis(delay.toMillis());\n\t}\n\n\t/**\n\t * Runs the start runnable after a certain delay. If called more than once, the following calls are ignored unless abort() is called first.\n\t */\n\tpublic void run()\n\t{\n\t\tif (shouldRun.compareAndSet(false, true))\n\t\t{\n\t\t\tcleanup();\n\t\t\tvar newTimeline = new Timeline(new KeyFrame(delay, event -> {\n\t\t\t\tif (start != null && shouldRun.get())\n\t\t\t\t{\n\t\t\t\t\tstart.run();\n\t\t\t\t}\n\t\t\t}));\n\t\t\ttimeline = newTimeline;\n\t\t\tPlatform.runLater(newTimeline::play);\n\t\t}\n\t}\n\n\t/**\n\t * Aborts the start runnable and runs the stop runnable.\n\t */\n\tpublic void abort()\n\t{\n\t\tif (shouldRun.compareAndSet(true, false))\n\t\t{\n\t\t\tcleanup();\n\t\t\tif (stop != null)\n\t\t\t{\n\t\t\t\tstop.run();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void cleanup()\n\t{\n\t\tif (timeline != null)\n\t\t{\n\t\t\ttimeline.stop();\n\t\t\ttimeline = null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/DelayedTooltip.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport javafx.scene.Node;\nimport javafx.scene.control.Tooltip;\n\nimport java.util.function.Consumer;\n\n/**\n * A tooltip subclass that allows to generate the string on demand (for example\n * for a network call).\n */\npublic class DelayedTooltip extends Tooltip\n{\n\tprivate Consumer<DelayedTooltip> consumer;\n\n\t/**\n\t * Creates a DelayedTooltip that will call the consumer when it's about to show.\n\t * The consumer has to call {@link #show(String)} to make the tooltip visible.\n\t *\n\t * @param consumer the consumer\n\t */\n\tpublic DelayedTooltip(Consumer<DelayedTooltip> consumer)\n\t{\n\t\tsuper();\n\t\tthis.consumer = consumer;\n\t}\n\n\t@Override\n\tprotected void show()\n\t{\n\t\tsuper.show();\n\t\tif (consumer != null)\n\t\t{\n\t\t\tconsumer.accept(this);\n\t\t}\n\t}\n\n\tpublic void show(String text)\n\t{\n\t\tconsumer = null; // Only fetch once\n\t\tsetText(text);\n\t}\n\n\tpublic void show(String text, Node graphic)\n\t{\n\t\tshow(text);\n\t\tsetGraphic(graphic);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/DisclosedHyperlink.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.util.UriUtils;\nimport javafx.application.HostServices;\nimport javafx.beans.NamedArg;\nimport javafx.beans.property.ObjectProperty;\nimport javafx.beans.property.ObjectPropertyBase;\nimport javafx.event.ActionEvent;\nimport javafx.event.EventHandler;\nimport javafx.scene.Cursor;\nimport javafx.scene.Node;\nimport javafx.scene.text.Text;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\n\n/**\n * Special Hyperlink-like class that offers the following benefits:\n * <ul>\n * <li>detects malicious links and warns about them (for example, a link that has a description of <a href=\"https://foo.com\">https://foo.bar</a> but really goes to <a href=\"https://bar.com\">https://bar.com</a>\n * <li>can be reflowed when put on a TextFlow\n * </ul>\n * On the other hand, it doesn't support the \"visited\" feature of normal hyperlinks.\n * <p>\n * Note: you most certainly want to use {@link UiUtils#linkify(Node, HostServices)} to have the link's action perform something.\n */\npublic class DisclosedHyperlink extends Text\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(DisclosedHyperlink.class);\n\n\tprivate String uri;\n\tprivate final boolean alwaysSafe;\n\tprivate boolean malicious;\n\tprivate boolean unsafe;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t/**\n\t * Creates a new DisclosedHyperlink\n\t *\n\t * @param text       the text to show in the link\n\t * @param uri        the URL\n\t * @param alwaysSafe disables safe/malicious detection (useful when linking to localhost APIs)\n\t */\n\tpublic DisclosedHyperlink(@NamedArg(value = \"text\") String text, @NamedArg(value = \"url\") String uri, @NamedArg(value = \"alwaysSafe\") boolean alwaysSafe)\n\t{\n\t\tsuper(text);\n\t\tthis.alwaysSafe = alwaysSafe;\n\t\tsetUri(uri);\n\t\tsetUnderline(true);\n\t\tsetOnMouseEntered(_ -> setCursor(Cursor.HAND));\n\t\tsetOnMouseExited(_ -> setCursor(Cursor.DEFAULT));\n\t\tUiUtils.setOnPrimaryMouseClicked(this, _ -> {\n\t\t\tvar action = onAction.get();\n\t\t\tif (action != null)\n\t\t\t{\n\t\t\t\tonAction.get().handle(new ActionEvent());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"No action defined for hyperlink\");\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic final ObjectProperty<EventHandler<ActionEvent>> onActionProperty()\n\t{\n\t\treturn onAction;\n\t}\n\n\tpublic final void setOnAction(EventHandler<ActionEvent> value)\n\t{\n\t\tonActionProperty().set(value);\n\t}\n\n\tpublic final EventHandler<ActionEvent> getOnAction()\n\t{\n\t\treturn onActionProperty().get();\n\t}\n\n\tprivate final ObjectProperty<EventHandler<ActionEvent>> onAction = new ObjectPropertyBase<>()\n\t{\n\t\t@Override\n\t\tprotected void invalidated()\n\t\t{\n\t\t\tsetEventHandler(ActionEvent.ACTION, get());\n\t\t}\n\n\t\t@Override\n\t\tpublic Object getBean()\n\t\t{\n\t\t\treturn DisclosedHyperlink.this;\n\t\t}\n\n\t\t@Override\n\t\tpublic String getName()\n\t\t{\n\t\t\treturn \"onAction\";\n\t\t}\n\t};\n\n\tpublic String getUri()\n\t{\n\t\treturn uri;\n\t}\n\n\tpublic void setUri(String uri)\n\t{\n\t\tthis.uri = uri;\n\t\tunsafe = uri != null && !alwaysSafe && !UriUtils.isSafeEnough(uri);\n\t\tmalicious = uri != null && !alwaysSafe && getText().contains(\"://\") && !getText().equals(uri);\n\n\t\tif (unsafe || malicious)\n\t\t{\n\t\t\tsetStyle(\"-fx-fill: -color-danger-fg;\");\n\t\t\tTooltipUtils.install(this, MessageFormat.format(bundle.getString(unsafe ? \"uri.unsafe-link\" : \"uri.malicious-link\"), uri));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsetStyle(\"-fx-fill: -color-accent-fg\");\n\t\t\tTooltipUtils.install(this, uri);\n\t\t}\n\t}\n\n\tpublic boolean isMalicious()\n\t{\n\t\treturn malicious;\n\t}\n\n\tpublic boolean isUnsafe()\n\t{\n\t\treturn unsafe;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/EditorView.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.image.ImageUtils;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.markdown.MarkdownService.Rendering;\nimport io.xeres.ui.support.markdown.UriAction;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.TextInputControlUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.beans.property.BooleanProperty;\nimport javafx.beans.property.ReadOnlyIntegerWrapper;\nimport javafx.beans.property.SimpleBooleanProperty;\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.control.*;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyCodeCombination;\nimport javafx.scene.input.KeyCombination;\nimport javafx.scene.input.KeyEvent;\nimport javafx.scene.layout.VBox;\nimport javafx.scene.text.TextFlow;\nimport javafx.stage.Window;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignL;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.ResourceBundle;\nimport java.util.regex.Pattern;\n\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\npublic class EditorView extends VBox\n{\n\tprivate static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination ENTER_INSERT_KEY = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN);\n\n\tprivate static final KeyCodeCombination MAKE_BOLD = new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_ITALIC = new KeyCodeCombination(KeyCode.I, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_CODE = new KeyCodeCombination(KeyCode.K, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_LINK = new KeyCodeCombination(KeyCode.L, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_UNORDERED_LIST = new KeyCodeCombination(KeyCode.U, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_ORDERED_LIST = new KeyCodeCombination(KeyCode.U, KeyCombination.SHIFT_DOWN, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_QUOTE = new KeyCodeCombination(KeyCode.Q, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_HEADER_1 = new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_HEADER_2 = new KeyCodeCombination(KeyCode.DIGIT2, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_HEADER_3 = new KeyCodeCombination(KeyCode.DIGIT3, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_HEADER_4 = new KeyCodeCombination(KeyCode.DIGIT4, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_HEADER_5 = new KeyCodeCombination(KeyCode.DIGIT5, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination MAKE_HEADER_6 = new KeyCodeCombination(KeyCode.DIGIT6, KeyCombination.SHORTCUT_DOWN);\n\tprivate static final KeyCodeCombination PREVIEW = new KeyCodeCombination(KeyCode.F12);\n\tprivate static final KeyCodeCombination REDO = new KeyCodeCombination(KeyCode.Z, KeyCombination.SHIFT_DOWN, KeyCombination.SHORTCUT_DOWN);\n\n\tprivate static final int IMAGE_WIDTH_MAX = 640;\n\tprivate static final int IMAGE_HEIGHT_MAX = 480;\n\tprivate static final int IMAGE_MAXIMUM_SIZE = 31000; // Same as the one in chat\n\n\tprivate static final Pattern URL_DETECTOR = Pattern.compile(\"(^mailto:.*$|^\\\\p{Ll}.{1,30}://.*$)\");\n\n\t@FXML\n\tprivate ToolBar toolBar;\n\n\t@FXML\n\tprivate Button undo;\n\n\t@FXML\n\tprivate Button redo;\n\n\t@FXML\n\tprivate Button bold;\n\n\t@FXML\n\tprivate Button italic;\n\n\t@FXML\n\tprivate Button hyperlink;\n\n\t@FXML\n\tprivate Button quote;\n\n\t@FXML\n\tprivate Button code;\n\n\t@FXML\n\tprivate Button unorderedList;\n\n\t@FXML\n\tprivate Button orderedList;\n\n\t@FXML\n\tprivate MenuButton heading;\n\n\t@FXML\n\tprivate MenuItem header1;\n\n\t@FXML\n\tprivate MenuItem header2;\n\n\t@FXML\n\tprivate MenuItem header3;\n\n\t@FXML\n\tprivate MenuItem header4;\n\n\t@FXML\n\tprivate MenuItem header5;\n\n\t@FXML\n\tprivate MenuItem header6;\n\n\t@FXML\n\tprivate ToggleButton preview;\n\n\t@FXML\n\tprivate TextArea editor;\n\n\t@FXML\n\tprivate ScrollPane previewPane;\n\n\t@FXML\n\tprivate TextFlow previewContent;\n\n\tprivate int typingCount;\n\n\tprivate MarkdownService markdownService;\n\n\tprivate final ResourceBundle bundle;\n\n\tpublic final ReadOnlyIntegerWrapper lengthProperty = new ReadOnlyIntegerWrapper();\n\n\tprivate final BooleanProperty previewOnly = new SimpleBooleanProperty(false);\n\n\tprivate UriAction uriAction;\n\n\tpublic EditorView()\n\t{\n\t\tbundle = I18nUtils.getBundle();\n\n\t\tvar loader = new FXMLLoader(EditorView.class.getResource(\"/view/custom/editor_view.fxml\"), bundle);\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\t@FXML\n\tprivate void initialize()\n\t{\n\t\tundo.disableProperty().bind(editor.undoableProperty().not());\n\t\tundo.setOnAction(_ -> editor.undo());\n\n\t\tredo.disableProperty().bind(editor.redoableProperty().not());\n\t\tredo.setOnAction(_ -> editor.redo());\n\n\t\tbold.setOnAction(_ -> surround(\"**\"));\n\t\titalic.setOnAction(_ -> surround(\"_\"));\n\t\tcode.setOnAction(_ -> makeCode());\n\t\tquote.setOnAction(_ -> prefixLines(\">\"));\n\t\tunorderedList.setOnAction(_ -> insertNextLine(\"-\"));\n\t\torderedList.setOnAction(_ -> insertNextLine(\"1.\"));\n\t\theader1.setOnAction(_ -> makeHeader(1));\n\t\theader2.setOnAction(_ -> makeHeader(2));\n\t\theader3.setOnAction(_ -> makeHeader(3));\n\t\theader4.setOnAction(_ -> makeHeader(4));\n\t\theader5.setOnAction(_ -> makeHeader(5));\n\t\theader6.setOnAction(_ -> makeHeader(6));\n\t\thyperlink.setOnAction(event -> insertUrl(UiUtils.getWindow(event)));\n\n\t\teditor.addEventFilter(KeyEvent.KEY_PRESSED, this::handleInputKeys);\n\n\t\tpreviewPane.addEventFilter(KeyEvent.KEY_PRESSED, event -> {\n\t\t\tif (PREVIEW.match(event))\n\t\t\t{\n\t\t\t\tpreview.fire();\n\t\t\t}\n\t\t});\n\n\t\tpreview.setOnAction(_ -> {\n\t\t\tvar selected = preview.isSelected();\n\t\t\tif (selected)\n\t\t\t{\n\t\t\t\tundo.disableProperty().unbind();\n\t\t\t\tredo.disableProperty().unbind();\n\t\t\t\tundo.setDisable(true);\n\t\t\t\tredo.setDisable(true);\n\t\t\t\teditor.setVisible(false);\n\t\t\t\tvar contents = markdownService.parse(editor.getText(), EnumSet.noneOf(Rendering.class), null);\n\t\t\t\tpreviewContent.getChildren().addAll(contents.stream()\n\t\t\t\t\t\t.map(Content::getNode).toList());\n\t\t\t\tpreviewPane.setVisible(true);\n\t\t\t\tpreviewPane.requestFocus();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tundo.setDisable(false);\n\t\t\t\tredo.setDisable(false);\n\t\t\t\tundo.disableProperty().bind(editor.undoableProperty().not());\n\t\t\t\tredo.disableProperty().bind(editor.redoableProperty().not());\n\t\t\t\teditor.setVisible(true);\n\t\t\t\tpreviewPane.setVisible(false);\n\t\t\t\tpreviewContent.getChildren().clear();\n\t\t\t\teditor.requestFocus();\n\t\t\t}\n\t\t\tbold.setDisable(selected);\n\t\t\titalic.setDisable(selected);\n\t\t\thyperlink.setDisable(selected);\n\t\t\tquote.setDisable(selected);\n\t\t\tcode.setDisable(selected);\n\t\t\tunorderedList.setDisable(selected);\n\t\t\torderedList.setDisable(selected);\n\t\t\theading.setDisable(selected);\n\t\t});\n\n\t\tlengthProperty.bind(editor.lengthProperty());\n\n\t\tpreviewOnly.addListener((_, _, newValue) -> {\n\t\t\tif (Boolean.TRUE.equals(newValue))\n\t\t\t{\n\t\t\t\tUiUtils.setAbsent(toolBar);\n\t\t\t\teditor.setVisible(false);\n\t\t\t\tpreviewPane.setVisible(true);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tUiUtils.setPresent(toolBar);\n\t\t\t\teditor.setVisible(true);\n\t\t\t\tpreviewPane.setVisible(false);\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void makeCode()\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tif (selection.getLength() <= 0)\n\t\t{\n\t\t\tif (isBeginningOfLine(editor.getCaretPosition()))\n\t\t\t{\n\t\t\t\tsurround(\"```\\n\", \"\\n```\");\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsurround(\"`\");\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (isParagraphBoundaries())\n\t\t\t{\n\t\t\t\tsurround(\"```\\n\", \"\\n```\");\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsurround(\"`\");\n\t\t\t}\n\t\t}\n\t\teditor.requestFocus();\n\t}\n\n\tprivate void makeHeader(int level)\n\t{\n\t\tinsertNextLine(\"#\".repeat(Math.max(0, level)));\n\t}\n\n\tprivate void handleInputKeys(KeyEvent event)\n\t{\n\t\ttypingCount++;\n\n\t\tif (PASTE_KEY.match(event))\n\t\t{\n\t\t\tif (handlePaste(editor))\n\t\t\t{\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t}\n\t\telse if (ENTER_INSERT_KEY.match(event))\n\t\t{\n\t\t\tcompleteStatement();\n\t\t}\n\t\telse if (MAKE_BOLD.match(event))\n\t\t{\n\t\t\tbold.fire();\n\t\t}\n\t\telse if (MAKE_ITALIC.match(event))\n\t\t{\n\t\t\titalic.fire();\n\t\t}\n\t\telse if (MAKE_CODE.match(event))\n\t\t{\n\t\t\tmakeCode();\n\t\t}\n\t\telse if (MAKE_LINK.match(event))\n\t\t{\n\t\t\tinsertUrl(UiUtils.getWindow(event));\n\t\t}\n\t\telse if (MAKE_UNORDERED_LIST.match(event))\n\t\t{\n\t\t\tunorderedList.fire();\n\t\t}\n\t\telse if (MAKE_ORDERED_LIST.match(event))\n\t\t{\n\t\t\torderedList.fire();\n\t\t}\n\t\telse if (MAKE_QUOTE.match(event))\n\t\t{\n\t\t\tquote.fire();\n\t\t}\n\t\telse if (MAKE_HEADER_1.match(event))\n\t\t{\n\t\t\tmakeHeader(1);\n\t\t}\n\t\telse if (MAKE_HEADER_2.match(event))\n\t\t{\n\t\t\tmakeHeader(2);\n\t\t}\n\t\telse if (MAKE_HEADER_3.match(event))\n\t\t{\n\t\t\tmakeHeader(3);\n\t\t}\n\t\telse if (MAKE_HEADER_4.match(event))\n\t\t{\n\t\t\tmakeHeader(4);\n\t\t}\n\t\telse if (MAKE_HEADER_5.match(event))\n\t\t{\n\t\t\tmakeHeader(5);\n\t\t}\n\t\telse if (MAKE_HEADER_6.match(event))\n\t\t{\n\t\t\tmakeHeader(6);\n\t\t}\n\t\telse if (PREVIEW.match(event))\n\t\t{\n\t\t\tpreview.fire();\n\t\t}\n\t\telse if (SystemUtils.IS_OS_WINDOWS && REDO.match(event))\n\t\t{\n\t\t\teditor.redo();\n\t\t}\n\t}\n\n\tpublic void setUriAction(UriAction uriAction)\n\t{\n\t\tthis.uriAction = uriAction;\n\t}\n\n\tpublic void setMarkdown(InputStream input)\n\t{\n\t\tif (!previewOnly.get())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Markdown file can only be set to an EditorView in previewOnly mode\");\n\t\t}\n\n\t\tif (markdownService == null)\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Use setMarkdownService() to set a markdown service before\");\n\t\t}\n\n\t\tList<Content> contents;\n\n\t\ttry\n\t\t{\n\t\t\tcontents = markdownService.parse(new String(input.readAllBytes(), StandardCharsets.UTF_8), EnumSet.noneOf(Rendering.class), uriAction);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tcontents = List.of(new ContentText(\"Couldn't open markdown file \" + input + \" (\" + e.getMessage() + \")\"));\n\t\t}\n\t\tpreviewContent.getChildren().clear();\n\t\tpreviewContent.getChildren().addAll(contents.stream()\n\t\t\t\t.map(Content::getNode).toList());\n\n\t\tpreviewPane.setVvalue(0.0); // Move to the top of the new page\n\t}\n\n\t/**\n\t * Sets the markdown service. If it is set, then the EditorView automatically gets a preview button.\n\t *\n\t * @param markdownService the markdown service\n\t */\n\tpublic void setMarkdownService(MarkdownService markdownService)\n\t{\n\t\tthis.markdownService = markdownService;\n\n\t\tUiUtils.setPresent(preview);\n\t}\n\n\tpublic String getText()\n\t{\n\t\treturn editor.getText();\n\t}\n\n\tpublic void setText(String text)\n\t{\n\t\teditor.setText(text);\n\t}\n\n\tpublic void setReply(String reply)\n\t{\n\t\tif (!reply.isBlank())\n\t\t{\n\t\t\treply = \"\\n\\n> \" + reply.replace(\"\\n\", \"\\n> \");\n\t\t\tif (reply.endsWith(\"\\n> \"))\n\t\t\t{\n\t\t\t\treply = reply.substring(0, reply.length() - 3);\n\t\t\t}\n\t\t}\n\t\teditor.setText(reply);\n\t\teditor.positionCaret(0);\n\t\teditor.requestFocus();\n\t}\n\n\tpublic void setInputContextMenu(LocationClient locationClient)\n\t{\n\t\tTextInputControlUtils.addEnhancedInputContextMenu(editor, locationClient, this::handlePaste);\n\t}\n\n\tpublic boolean isModified()\n\t{\n\t\treturn typingCount >= 2;\n\t}\n\n\tpublic boolean isPreviewOnly()\n\t{\n\t\treturn previewOnly.get();\n\t}\n\n\tpublic void setPreviewOnly(boolean previewOnly)\n\t{\n\t\tthis.previewOnly.set(previewOnly);\n\t}\n\n\tpublic void setPrompt(String text)\n\t{\n\t\teditor.setPromptText(text);\n\t}\n\n\tpublic String getPrompt()\n\t{\n\t\treturn editor.getPromptText();\n\t}\n\n\t// XXX: remove!\n\tprivate void surround(String text)\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tif (selection.getLength() <= 0)\n\t\t{\n\t\t\tvar pos = editor.getCaretPosition();\n\t\t\teditor.insertText(pos, text + text);\n\t\t\teditor.positionCaret(pos + text.length());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar trailingSpace = editor.getText(selection.getEnd() - 1, selection.getEnd()).equals(\" \");\n\t\t\teditor.insertText(selection.getStart(), text);\n\t\t\tvar end = selection.getEnd() + text.length();\n\t\t\tif (trailingSpace)\n\t\t\t{\n\t\t\t\tend--;\n\t\t\t}\n\t\t\teditor.insertText(end, text);\n\t\t\teditor.positionCaret(end + text.length() + 1);\n\t\t}\n\t\teditor.requestFocus();\n\t}\n\n\tprivate void surround(String before, String after)\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tif (selection.getLength() <= 0)\n\t\t{\n\t\t\tvar pos = editor.getCaretPosition();\n\t\t\teditor.insertText(pos, before + after);\n\t\t\teditor.positionCaret(pos + before.length());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar trailingSpace = editor.getText(selection.getEnd() - 1, selection.getEnd()).equals(\" \");\n\t\t\teditor.insertText(selection.getStart(), before);\n\t\t\tvar end = selection.getEnd() + before.length();\n\t\t\tif (trailingSpace)\n\t\t\t{\n\t\t\t\tend--;\n\t\t\t}\n\t\t\teditor.insertText(end, after);\n\t\t\teditor.positionCaret(end + after.length() + 1);\n\t\t}\n\t\teditor.requestFocus();\n\t}\n\n\tprivate void prefixLines(String text)\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tif (selection.getLength() <= 0)\n\t\t{\n\t\t\tprefixSingleLine(text);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tprefixParagraph(text, selection);\n\t\t\teditor.insertText(editor.getCaretPosition(), \"\\n\\n\");\n\t\t}\n\t\teditor.requestFocus();\n\t}\n\n\tprivate void prefixSingleLine(String text)\n\t{\n\t\tvar pos = editor.getCaretPosition();\n\t\tif (isBeginningOfLine(pos))\n\t\t{\n\t\t\teditor.insertText(pos, text + \" \");\n\t\t}\n\t}\n\n\tprivate void prefixParagraph(String text, IndexRange selection)\n\t{\n\t\tif (isParagraphBoundaries())\n\t\t{\n\t\t\tvar start = selection.getStart();\n\t\t\tint end;\n\t\t\tvar selectionEnd = selection.getEnd();\n\n\t\t\tvar textToInsert = text + (text.isBlank() ? \"\" : \" \"); // spacing is not needed for indentation or so\n\n\t\t\twhile (start <= selectionEnd)\n\t\t\t{\n\t\t\t\tend = editor.getText(start, selectionEnd).indexOf(\"\\n\");\n\t\t\t\tif (end == -1)\n\t\t\t\t{\n\t\t\t\t\tend = selectionEnd;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tend += start;\n\t\t\t\t}\n\n\t\t\t\teditor.insertText(start, textToInsert);\n\t\t\t\teditor.positionCaret(end + 1 + textToInsert.length());\n\n\t\t\t\tselectionEnd += textToInsert.length();\n\n\t\t\t\tstart = end + 1 + textToInsert.length();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void insertNextLine(String text)\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tif (selection.getLength() <= 0)\n\t\t{\n\t\t\tvar pos = editor.getCaretPosition();\n\t\t\tif (isBeginningOfLine(pos))\n\t\t\t{\n\t\t\t\teditor.insertText(pos, text + \" \");\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\teditor.insertText(pos, \"\\n\" + text + \" \");\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar selectedText = editor.getText(selection.getStart(), selection.getEnd());\n\t\t\tif (!selectedText.contains(\"\\n\") && selection.getEnd() == editor.getLength())\n\t\t\t{\n\t\t\t\teditor.insertText(selection.getStart(), \"\\n\" + text + \" \");\n\t\t\t}\n\t\t}\n\t\teditor.requestFocus();\n\t}\n\n\tprivate boolean isBeginningOfLine(int pos)\n\t{\n\t\treturn pos == 0 || editor.getText(pos - 1, pos).equals(\"\\n\");\n\t}\n\n\tprivate void insertUrl(Window parent)\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tvar dialog = new TextInputDialog();\n\t\tdialog.setTitle(bundle.getString(\"editor.hyperlink.enter\"));\n\t\tdialog.setHeaderText(null);\n\t\tdialog.setGraphic(new FontIcon(MaterialDesignL.LINK_VARIANT));\n\t\tdialog.initOwner(parent);\n\n\t\tdialog.showAndWait().ifPresent(link -> {\n\t\t\tif (isNotBlank(link))\n\t\t\t{\n\t\t\t\tif (!URL_DETECTOR.matcher(link).matches())\n\t\t\t\t{\n\t\t\t\t\tlink = \"https://\" + link;\n\t\t\t\t}\n\t\t\t\tif (selection.getLength() <= 0)\n\t\t\t\t{\n\t\t\t\t\tvar pos = editor.getCaretPosition();\n\n\t\t\t\t\teditor.insertText(pos, \"[](\" + link + \")\");\n\t\t\t\t\teditor.positionCaret(pos + 1);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\teditor.insertText(selection.getStart(), \"[\");\n\t\t\t\t\teditor.insertText(editor.getText(selection.getEnd(), selection.getEnd() + 1).equals(\" \") ? selection.getEnd() : (selection.getEnd() + 1), \"](\" + link + \")\");\n\t\t\t\t}\n\t\t\t}\n\t\t\teditor.requestFocus();\n\t\t});\n\t}\n\n\tprivate boolean isParagraphBoundaries()\n\t{\n\t\tvar selection = editor.getSelection();\n\n\t\tvar start = selection.getStart();\n\t\tvar end = selection.getEnd();\n\n\t\treturn (start == 0 || editor.getText(start - 1, start).equals(\"\\n\")) && (editor.getText(end - 1, end).equals(\"\\n\") || end == editor.getLength() || editor.getText(end, end + 1).equals(\"\\n\"));\n\t}\n\n\tprivate boolean handlePaste(TextInputControl textInputControl)\n\t{\n\t\tvar object = ClipboardUtils.getSupportedObjectFromClipboard();\n\t\treturn switch (object)\n\t\t{\n\t\t\tcase Image image ->\n\t\t\t{\n\t\t\t\tvar imageView = new ImageView(image);\n\t\t\t\tImageViewUtils.limitMaximumImageSize(imageView, IMAGE_WIDTH_MAX * IMAGE_HEIGHT_MAX);\n\n\t\t\t\tvar imgData = ImageUtils.writeImage(SwingFXUtils.fromFXImage(imageView.getImage(), null), IMAGE_MAXIMUM_SIZE);\n\t\t\t\ttextInputControl.insertText(textInputControl.getCaretPosition(), \"![](\" + imgData + \")\");\n\n\t\t\t\tyield true;\n\t\t\t}\n\t\t\tcase String string ->\n\t\t\t{\n\t\t\t\ttextInputControl.insertText(textInputControl.getCaretPosition(), string);\n\t\t\t\tyield true;\n\t\t\t}\n\t\t\tcase null, default -> false;\n\t\t};\n\t}\n\n\t/**\n\t * Inserts a new line without cutting the current line.\n\t */\n\tprivate void completeStatement()\n\t{\n\t\tvar s = editor.getText(editor.getCaretPosition(), editor.getLength());\n\t\tvar eol = s.indexOf('\\n');\n\t\tif (eol == -1)\n\t\t{\n\t\t\teol = s.length();\n\t\t}\n\t\teditor.insertText(editor.getCaretPosition() + eol, \"\\n\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/ImageSelectorView.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.custom.asyncimage.PlaceholderImageView;\nimport javafx.beans.NamedArg;\nimport javafx.beans.property.ObjectProperty;\nimport javafx.event.ActionEvent;\nimport javafx.event.EventHandler;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.control.Button;\nimport javafx.scene.image.Image;\nimport javafx.scene.layout.StackPane;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.util.ResourceBundle;\nimport java.util.function.Function;\n\n/**\n * A class that allows to select/remove an image. It can be supplied with a placeholder to show when there's\n * no image selected yet. The placeholder is an iconLiteral from FontIcon (for example mdi2i-image-plus).\n */\npublic class ImageSelectorView extends StackPane\n{\n\tprivate static final double BUTTON_OPACITY = 0.8;\n\n\t@FXML\n\tprivate PlaceholderImageView placeholderImageView;\n\n\t@FXML\n\tprivate Button selectButton;\n\n\t@FXML\n\tprivate Button deleteButton;\n\n\tprivate boolean deletable = true;\n\n\tprivate final Double fitWidth;\n\tprivate final Double fitHeight;\n\tprivate final String placeholder;\n\tprivate final Boolean autoResize;\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate String url;\n\n\tpublic ImageSelectorView(@NamedArg(value = \"fitWidth\", defaultValue = \"64.0\") Double fitWidth, @NamedArg(value = \"fitHeight\", defaultValue = \"64.0\") Double fitHeight, @NamedArg(value = \"placeholder\") String placeholder, @NamedArg(value = \"autoResize\", defaultValue = \"false\") Boolean autoResize)\n\t{\n\t\tsuper();\n\n\t\tbundle = I18nUtils.getBundle();\n\n\t\tthis.fitWidth = fitWidth;\n\t\tthis.fitHeight = fitHeight;\n\t\tthis.placeholder = placeholder;\n\t\tthis.autoResize = autoResize;\n\n\t\tvar loader = new FXMLLoader(ImageSelectorView.class.getResource(\"/view/custom/image_selector_view.fxml\"), bundle);\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\t@FXML\n\tprivate void initialize()\n\t{\n\t\tif (fitWidth != null && fitWidth != 0)\n\t\t{\n\t\t\tplaceholderImageView.setFitWidth(fitWidth);\n\t\t}\n\t\tif (fitHeight != null && fitHeight != 0)\n\t\t{\n\t\t\tplaceholderImageView.setFitHeight(fitHeight);\n\t\t}\n\t\tif (autoResize != null)\n\t\t{\n\t\t\tplaceholderImageView.setPreserveRatio(autoResize);\n\t\t}\n\t\tif (StringUtils.isNotBlank(placeholder))\n\t\t{\n\t\t\tplaceholderImageView.setIconLiteral(placeholder);\n\t\t\tplaceholderImageView.showDefault();\n\t\t}\n\n\t\tcomputeActionText();\n\n\t\tplaceholderImageView.setOnMouseEntered(_ -> setImageOpacity(BUTTON_OPACITY));\n\t\tplaceholderImageView.setOnMouseExited(_ -> setImageOpacity(0.0));\n\t\tselectButton.setOnMouseEntered(_ -> setImageOpacity(BUTTON_OPACITY));\n\t\tselectButton.setOnMouseExited(_ -> setImageOpacity(0.0));\n\t\tdeleteButton.setOnMouseEntered(_ -> setImageOpacity(BUTTON_OPACITY));\n\t\tdeleteButton.setOnMouseExited(_ -> setImageOpacity(0.0));\n\n\t\tplaceholderImageView.imageProperty().addListener((_, _, newValue) -> {\n\t\t\tif (newValue != null && !newValue.isError())\n\t\t\t{\n\t\t\t\tif (deletable)\n\t\t\t\t{\n\t\t\t\t\tdeleteButton.setVisible(true);\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (deletable)\n\t\t\t\t{\n\t\t\t\t\tdeleteButton.setVisible(false);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcomputeActionText();\n\t\t});\n\t}\n\n\tpublic ObjectProperty<Image> imageProperty()\n\t{\n\t\treturn placeholderImageView.imageProperty();\n\t}\n\n\t/**\n\t * Sets the image loader. Only needed when loading from a URL is needed.\n\t *\n\t * @param loader the loader\n\t */\n\tpublic void setImageLoader(Function<String, byte[]> loader)\n\t{\n\t\tplaceholderImageView.setLoader(loader);\n\t}\n\n\t/**\n\t * Sets the image cache. Only needed when images are frequently loaded.\n\t * @param imageCache the image cache\n\t */\n\tpublic void setImageCache(ImageCache imageCache)\n\t{\n\t\tplaceholderImageView.setImageCache(imageCache);\n\t}\n\n\t/**\n\t * Sets the image URL. It will be loaded asynchronously.\n\t * @param url the url\n\t */\n\tpublic void setImageUrl(String url)\n\t{\n\t\tthis.url = url;\n\t\tplaceholderImageView.setUrl(url);\n\t}\n\n\t/// Sets the file. It will be loaded asynchronously. Very useful when using a requester, for example:\n\t///\n\t/// ```java\n    /// File selectedFile = fileChooser.showOpenDialog(getWindow(event));\n    /// imageSelectorView.setFile(selectedFile);\n    /// ```\n\t///\n\t/// @param file the file to load\n\tpublic void setFile(File file)\n\t{\n\t\tif (file != null && file.canRead())\n\t\t{\n\t\t\turl = file.toURI().toASCIIString();\n\t\t\tplaceholderImageView.setUrl(url);\n\t\t}\n\t}\n\n\t/**\n\t * Gets the URL that this SelectorView was set to. Also returns file URLs.\n\t *\n\t * @return the URL, null if not URL was set\n\t */\n\tpublic String getUrl()\n\t{\n\t\treturn url;\n\t}\n\n\t/**\n\t * Gets the file that was set to this SelectorView, if any.\n\t *\n\t * @return the file, null if no file was set or the source image didn't come from any\n\t */\n\tpublic File getFile()\n\t{\n\t\tif (url == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tFile file;\n\t\ttry\n\t\t{\n\t\t\tfile = new File(new URI(url));\n\t\t}\n\t\tcatch (URISyntaxException | IllegalArgumentException _)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\tif (file.canRead())\n\t\t{\n\t\t\treturn file;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Sets the image that will be shown.\n\t * @param image the image\n\t */\n\tpublic void setImage(Image image)\n\t{\n\t\turl = null;\n\t\tplaceholderImageView.updateImage(image);\n\t}\n\n\t/**\n\t * The action to execute when the image selector button is pressed.\n\t * @param value the action event\n\t */\n\tpublic void setOnSelectAction(EventHandler<ActionEvent> value)\n\t{\n\t\tselectButton.setOnAction(value);\n\t}\n\n\t/**\n\t * The action to execute when the image removal button is pressed.\n\t * @param value the action event\n\t */\n\tpublic void setOnDeleteAction(EventHandler<ActionEvent> value)\n\t{\n\t\tdeleteButton.setOnAction(value);\n\t}\n\n\t/**\n\t * Checks if there's an image set at all.\n\t * @return true if there's no image\n\t */\n\tpublic boolean isEmpty()\n\t{\n\t\treturn placeholderImageView.getImage() == null;\n\t}\n\n\t/**\n\t * Shows the edit buttons.\n\t *\n\t * @param editable true if the image can be added and removed\n\t */\n\tpublic void setEditable(boolean editable)\n\t{\n\t\tsetEditable(editable, editable);\n\t}\n\n\t/**\n\t * Shows the edit buttons.\n\t * <p>This version is needed in case an image is not a real image (for example autogenerated)\n\t * and hence the delete button would make no sense and has to be set to false.\n\t *\n\t * @param editable  true if an image can be added\n\t * @param deletable true if the image can also be removed\n\t */\n\tpublic void setEditable(boolean editable, boolean deletable)\n\t{\n\t\tthis.deletable = deletable;\n\n\t\tselectButton.setVisible(editable);\n\n\t\tif (deletable)\n\t\t{\n\t\t\tdeleteButton.setVisible(placeholderImageView.getImage() != null && !placeholderImageView.getImage().isError());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tdeleteButton.setVisible(false);\n\t\t}\n\t}\n\n\tprivate void setImageOpacity(double opacity)\n\t{\n\t\tselectButton.setOpacity(opacity);\n\t\tif (placeholderImageView.getImage() != null)\n\t\t{\n\t\t\tdeleteButton.setOpacity(opacity);\n\t\t}\n\t}\n\n\tprivate void computeActionText()\n\t{\n\t\tif (placeholderImageView.getImage() == null)\n\t\t{\n\t\t\tif (fitWidth <= 64)\n\t\t\t{\n\t\t\t\tselectButton.setText(bundle.getString(\"add\"));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tselectButton.setText(bundle.getString(\"image-selector-view.add-image\"));\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (fitWidth <= 64)\n\t\t\t{\n\t\t\t\tselectButton.setText(bundle.getString(\"image-selector-view.change-image-short\"));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tselectButton.setText(bundle.getString(\"image-selector-view.change-image\"));\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/InfoView.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.support.util.TextFlowDragSelection;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Node;\nimport javafx.scene.control.ScrollPane;\nimport javafx.scene.text.TextFlow;\nimport org.apache.commons.collections4.CollectionUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.function.Function;\n\npublic class InfoView extends ScrollPane\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(InfoView.class);\n\n\t@FXML\n\tprivate AsyncImageView image;\n\n\t@FXML\n\tprivate TextFlow header;\n\n\t@FXML\n\tprivate TextFlow body;\n\n\tpublic InfoView()\n\t{\n\t\tvar loader = new FXMLLoader(InfoView.class.getResource(\"/view/custom/info_view.fxml\"));\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\t@FXML\n\tprivate void initialize()\n\t{\n\t\tTextFlowDragSelection.enableSelection(header, this);\n\t\tTextFlowDragSelection.enableSelection(body, this);\n\t}\n\n\tpublic void setLoader(Function<String, byte[]> loader)\n\t{\n\t\timage.setLoader(loader);\n\t}\n\n\tpublic void setInfo(List<Node> header, List<Node> body)\n\t{\n\t\tsetInfo(header, body, null, 0, 0);\n\t}\n\n\tpublic void setInfo(List<Node> header, List<Node> body, String imageUrl, int imageWidth, int imageHeight)\n\t{\n\t\tif (imageUrl != null && imageWidth > 0 && imageHeight > 0)\n\t\t{\n\t\t\tif (image.hasLoader())\n\t\t\t{\n\t\t\t\timage.setFitWidth(imageWidth);\n\t\t\t\timage.setFitHeight(imageHeight);\n\t\t\t\timage.setUrl(imageUrl);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.warn(\"InfoView has no loader set, url {} cannot be loaded. use setLoader() first\", imageUrl);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (imageUrl != null)\n\t\t\t{\n\t\t\t\tlog.warn(\"image width and height not supplied for url {}, not loading image\", imageUrl);\n\t\t\t}\n\t\t\timage.setUrl(null);\n\t\t\timage.setFitWidth(0);\n\t\t\timage.setFitHeight(0);\n\t\t}\n\n\t\tif (CollectionUtils.isNotEmpty(header))\n\t\t{\n\t\t\tthis.header.getChildren().setAll(header);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.header.getChildren().clear();\n\t\t}\n\t\tif (CollectionUtils.isNotEmpty(body))\n\t\t{\n\t\t\tthis.body.getChildren().setAll(body);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.body.getChildren().clear();\n\t\t}\n\t\tsetVvalue(getVmin()); // Needed when the content is smaller than the height, I think\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/InputArea.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.custom.alias.PopupAlias;\nimport io.xeres.ui.custom.event.StickerSelectedEvent;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.beans.binding.Bindings;\nimport javafx.beans.value.ChangeListener;\nimport javafx.scene.Group;\nimport javafx.scene.Node;\nimport javafx.scene.control.ScrollPane;\nimport javafx.scene.control.TextArea;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyCodeCombination;\nimport javafx.scene.input.KeyCombination;\nimport javafx.scene.input.KeyEvent;\nimport javafx.scene.layout.Region;\nimport javafx.scene.text.Text;\nimport javafx.stage.Popup;\nimport javafx.stage.PopupWindow;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.nio.file.Path;\nimport java.util.Set;\n\n/**\n * Input area widget.\n * <p>Autogrow system by Dirk Lemmermann, see\n * <a href=\"https://github.com/dlsc-software-consulting-gmbh/GemsFX/blob/master/gemsfx/src/main/java/com/dlsc/gemsfx/ExpandingTextArea.java\">GemsFX</a>\n */\npublic class InputArea extends TextArea\n{\n\tprivate static final String STICKERS_DIRECTORY = \"Stickers\";\n\tprivate static final KeyCodeCombination CTRL_S = new KeyCodeCombination(KeyCode.S, KeyCombination.CONTROL_DOWN);\n\n\tprivate Text text;\n\n\tprivate double offsetTop;\n\tprivate double offsetBottom;\n\n\tprivate PopupAlias popupAlias;\n\n\tpublic InputArea()\n\t{\n\t\tthis(\"\");\n\t}\n\n\tpublic InputArea(String text)\n\t{\n\t\tsuper(text);\n\t\tsetWrapText(true);\n\n\t\tsceneProperty().addListener(observable -> {\n\t\t\tif (getScene() != null)\n\t\t\t{\n\t\t\t\tperformBinding();\n\t\t\t}\n\t\t});\n\n\t\tskinProperty().addListener(observable -> {\n\t\t\tif (getSkin() != null)\n\t\t\t{\n\t\t\t\tperformBinding();\n\t\t\t}\n\t\t});\n\n\t\taddEventFilter(KeyEvent.KEY_PRESSED, this::handleInputKeys);\n\t\taddEventFilter(KeyEvent.KEY_TYPED, this::handleTypedKeys);\n\t}\n\n\tpublic void openStickerSelector()\n\t{\n\t\thandleStickers();\n\t}\n\n\tprivate void handleInputKeys(KeyEvent event)\n\t{\n\t\tif (CTRL_S.match(event))\n\t\t{\n\t\t\tif (handleStickers())\n\t\t\t{\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t}\n\t\telse if ((event.getCode() == KeyCode.BACK_SPACE && StringUtils.defaultString(getText()).length() == 1)\n\t\t\t\t&& popupAlias != null)\n\t\t{\n\t\t\tpopupAlias.hide();\n\t\t}\n\t}\n\n\tprivate void handleTypedKeys(KeyEvent event)\n\t{\n\t\tif (event.getCharacter().equals(\"/\") && StringUtils.defaultString(getText()).isEmpty()) // Only open if we type '/' alone\n\t\t{\n\t\t\thandleAliases();\n\t\t}\n\t}\n\n\tprivate boolean handleStickers()\n\t{\n\t\tvar bounds = localToScreen(getBoundsInLocal());\n\t\tvar popup = new Popup();\n\t\tvar stickerView = new StickerView();\n\t\tpopup.getContent().add(stickerView);\n\t\tpopup.setAnchorX(bounds.getMinX());\n\t\tpopup.setAnchorY(bounds.getMinY());\n\t\tpopup.setAnchorLocation(PopupWindow.AnchorLocation.CONTENT_BOTTOM_LEFT);\n\n\t\t// Proxy the event to the InputArea\n\t\tstickerView.addEventHandler(StickerSelectedEvent.STICKER_SELECTED, event -> {\n\t\t\tevent.consume();\n\t\t\tfireEvent(new StickerSelectedEvent(event.getPath()));\n\t\t\tpopup.hide();\n\t\t});\n\n\t\tpopup.show(UiUtils.getWindow(this));\n\t\tstickerView.loadStickers(\n\t\t\t\tPath.of(OsUtils.getApplicationHome().toString(), STICKERS_DIRECTORY),\n\t\t\t\tPath.of(OsUtils.getDataDir().toString(), STICKERS_DIRECTORY));\n\t\tpopup.setAutoHide(true);\n\t\treturn true;\n\t}\n\n\tprivate void handleAliases()\n\t{\n\t\tvar bounds = localToScreen(getBoundsInLocal());\n\t\tpopupAlias = new PopupAlias(bounds, alias -> {\n\t\t\tif (StringUtils.isNotEmpty(alias))\n\t\t\t{\n\t\t\t\tif (getText().length() < alias.length()) // Only complete if we're not ahead already by entering arguments and so on (they would get removed)\n\t\t\t\t{\n\t\t\t\t\tsetText(alias);\n\t\t\t\t\tpositionCaret(StringUtils.defaultString(getText()).length());\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tChangeListener<? super String> changeListener = (observable, oldValue, newValue) -> popupAlias.setFilter(newValue);\n\t\ttextProperty().addListener(changeListener);\n\n\t\tpopupAlias.show(UiUtils.getWindow(this));\n\t\tpopupAlias.setOnHidden(windowEvent -> {\n\t\t\ttextProperty().removeListener(changeListener);\n\t\t\tpopupAlias = null;\n\t\t});\n\t}\n\n\tprivate double computeHeight()\n\t{\n\t\tcomputeOffsets();\n\n\t\tvar bounds = localToScreen(text.getLayoutBounds());\n\t\tif (bounds != null)\n\t\t{\n\t\t\tvar minY = bounds.getMinY();\n\t\t\tvar maxY = bounds.getMaxY();\n\n\t\t\treturn maxY - minY + offsetTop + offsetBottom;\n\t\t}\n\t\treturn 0.0;\n\t}\n\n\tprivate void computeOffsets()\n\t{\n\t\toffsetTop = getInsets().getTop();\n\t\toffsetBottom = getInsets().getBottom();\n\n\t\tvar scrollPane = (ScrollPane) lookup(\".scroll-pane\");\n\t\tif (scrollPane != null)\n\t\t{\n\t\t\tvar viewport = (Region) scrollPane.lookup(\".viewport\");\n\t\t\tvar content = (Region) scrollPane.lookup(\".content\");\n\n\t\t\toffsetTop += viewport.getInsets().getTop();\n\t\t\toffsetTop += content.getInsets().getTop();\n\n\t\t\toffsetBottom += viewport.getInsets().getBottom();\n\t\t\toffsetBottom += content.getInsets().getBottom();\n\t\t}\n\t}\n\n\tprivate void performBinding()\n\t{\n\t\tvar scrollPane = (ScrollPane) lookup(\".scroll-pane\");\n\t\tif (scrollPane != null)\n\t\t{\n\t\t\tscrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);\n\t\t\tscrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);\n\t\t\tscrollPane.skinProperty().addListener(it -> {\n\t\t\t\tif (scrollPane.getSkin() != null)\n\t\t\t\t{\n\t\t\t\t\tif (text == null)\n\t\t\t\t\t{\n\t\t\t\t\t\ttext = findTextNode();\n\t\t\t\t\t\tif (text != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tprefHeightProperty().bind(Bindings.createDoubleBinding(this::computeHeight, text.layoutBoundsProperty()));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\tprivate Text findTextNode()\n\t{\n\t\tfinal Set<Node> nodes = lookupAll(\".text\");\n\t\tfor (Node node : nodes)\n\t\t{\n\t\t\tif (node.getParent() instanceof Group)\n\t\t\t{\n\t\t\t\treturn (Text) node;\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/InputAreaGroup.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.custom.event.FileSelectedEvent;\nimport io.xeres.ui.custom.event.ImageSelectedEvent;\nimport io.xeres.ui.support.util.ChooserUtils;\nimport io.xeres.ui.support.util.TextInputControlUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.beans.property.ReadOnlyBooleanProperty;\nimport javafx.event.EventHandler;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.control.Button;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.control.TextInputControl;\nimport javafx.scene.input.KeyEvent;\nimport javafx.scene.layout.HBox;\nimport javafx.stage.FileChooser;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignA;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignF;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\nimport java.util.function.Consumer;\n\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\n\npublic class InputAreaGroup extends HBox\n{\n\t@FXML\n\tprivate InputArea inputArea;\n\n\t@FXML\n\tprivate Button addMedia;\n\n\t@FXML\n\tprivate Button addSticker;\n\n\t@FXML\n\tprivate Button callButton;\n\n\tprivate final ResourceBundle bundle;\n\n\tpublic InputAreaGroup()\n\t{\n\t\tbundle = I18nUtils.getBundle();\n\n\t\tvar loader = new FXMLLoader(InputAreaGroup.class.getResource(\"/view/custom/input_area_group.fxml\"), bundle);\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic ReadOnlyBooleanProperty callPressedProperty()\n\t{\n\t\treturn callButton.pressedProperty();\n\t}\n\n\t@FXML\n\tprivate void initialize()\n\t{\n\t\tdisabledProperty().addListener((_, _, newValue) -> {\n\t\t\taddMedia.setDisable(newValue);\n\t\t\taddSticker.setDisable(newValue);\n\t\t});\n\n\t\taddSticker.setOnAction(_ -> inputArea.openStickerSelector());\n\n\t\tcreateAddMediaContextMenu();\n\t}\n\n\tpublic void clear()\n\t{\n\t\tinputArea.clear();\n\t}\n\n\tpublic void addKeyFilter(EventHandler<? super KeyEvent> eventFilter)\n\t{\n\t\tinputArea.addEventFilter(KeyEvent.KEY_PRESSED, eventFilter);\n\t}\n\n\tpublic void addEnhancedContextMenu(Consumer<TextInputControl> pasteAction)\n\t{\n\t\taddEnhancedContextMenu(pasteAction, null);\n\t}\n\n\tpublic void addEnhancedContextMenu(Consumer<TextInputControl> pasteAction, LocationClient locationClient)\n\t{\n\t\tTextInputControlUtils.addEnhancedInputContextMenu(inputArea, locationClient, pasteAction);\n\t}\n\n\tpublic TextInputControl getTextInputControl()\n\t{\n\t\treturn inputArea;\n\t}\n\n\t@Override\n\tpublic void requestFocus()\n\t{\n\t\tinputArea.requestFocus();\n\t}\n\n\t/**\n\t * Sets the input area to offline mode. Sending images, files and stickers will be disabled, but\n\t * text can still be entered.\n\t *\n\t * @param offline true if offline\n\t */\n\tpublic void setOffline(boolean offline)\n\t{\n\t\taddMedia.setDisable(offline);\n\t\taddSticker.setDisable(offline);\n\t}\n\n\tpublic void setVoipCapable(boolean voipCapable)\n\t{\n\t\tUiUtils.setPresent(callButton, voipCapable);\n\t}\n\n\tprivate void createAddMediaContextMenu()\n\t{\n\t\tvar addImageItem = new MenuItem(bundle.getString(\"messaging.action.send-inline\"));\n\t\taddImageItem.setGraphic(new FontIcon(MaterialDesignF.FILE_IMAGE_OUTLINE));\n\t\taddImageItem.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"messaging.file-requester.send-picture\"));\n\t\t\tChooserUtils.setSupportedLoadImageFormats(fileChooser);\n\t\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\t\tif (selectedFile != null)\n\t\t\t{\n\t\t\t\tfireEvent(new ImageSelectedEvent(selectedFile));\n\t\t\t}\n\t\t});\n\n\t\tvar addFileItem = new MenuItem(bundle.getString(\"messaging.action.send-file\"));\n\t\taddFileItem.setGraphic(new FontIcon(MaterialDesignA.ATTACHMENT));\n\t\taddFileItem.setOnAction(event -> {\n\t\t\tvar fileChooser = new FileChooser();\n\t\t\tfileChooser.setTitle(bundle.getString(\"messaging.file-requester.send-file\"));\n\t\t\tvar selectedFile = fileChooser.showOpenDialog(getWindow(event));\n\t\t\tif (selectedFile != null)\n\t\t\t{\n\t\t\t\tfireEvent(new FileSelectedEvent(selectedFile));\n\t\t\t}\n\t\t});\n\n\t\tvar contextMenu = new ContextMenu(addImageItem, addFileItem);\n\t\taddMedia.setOnContextMenuRequested(event -> {\n\t\t\tcontextMenu.show(addMedia, event.getScreenX(), event.getScreenY());\n\t\t\tevent.consume();\n\t\t});\n\t\tUiUtils.setOnPrimaryMouseClicked(addMedia, event -> contextMenu.show(addMedia, event.getScreenX(), event.getScreenY()));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/NullSelectionModel.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport javafx.collections.FXCollections;\nimport javafx.collections.ObservableList;\nimport javafx.scene.control.MultipleSelectionModel;\n\n/**\n * Allows to disable the selection; for example, in listviews.\n */\npublic class NullSelectionModel<T> extends MultipleSelectionModel<T>\n{\n\t@Override\n\tpublic ObservableList<Integer> getSelectedIndices()\n\t{\n\t\treturn FXCollections.emptyObservableList();\n\t}\n\n\t@Override\n\tpublic ObservableList<T> getSelectedItems()\n\t{\n\t\treturn FXCollections.emptyObservableList();\n\t}\n\n\t@Override\n\tpublic void selectIndices(int index, int... indices)\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void selectAll()\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void clearAndSelect(int index)\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void select(int index)\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void select(T obj)\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void clearSelection(int index)\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void clearSelection()\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic boolean isSelected(int index)\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic boolean isEmpty()\n\t{\n\t\treturn false;\n\t}\n\n\t@Override\n\tpublic void selectPrevious()\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void selectNext()\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void selectFirst()\n\t{\n\t\t// Disabled\n\t}\n\n\t@Override\n\tpublic void selectLast()\n\t{\n\t\t// Disabled\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/ProgressPane.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport javafx.scene.control.ProgressIndicator;\nimport javafx.scene.layout.StackPane;\n\nimport java.time.Duration;\n\n/**\n * Pane showing an intelligent undetermined progress.\n */\npublic class ProgressPane extends StackPane\n{\n\tprivate static final Duration PROGRESS_SHOW_DELAY = Duration.ofMillis(250);\n\n\tprivate ProgressIndicator progressIndicator;\n\tprivate DelayedAction delayedAction;\n\n\t/**\n\t * Shows the progress, but only after a certain delay, to avoid UI flickering in case the progress is quick.\n\t *\n\t * @param show {@code true} to show the progress, {@code false} to remove it.\n\t */\n\tpublic void showProgress(boolean show)\n\t{\n\t\tsetupProgressIndicatorIfNeeded();\n\n\t\tif (show)\n\t\t{\n\t\t\tdelayedAction.run();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tdelayedAction.abort();\n\t\t}\n\t}\n\n\tprivate void showProgressIndicator(boolean show)\n\t{\n\t\tgetChildrenUnmodifiable().getFirst().setVisible(!show);\n\t\tprogressIndicator.setVisible(show);\n\t}\n\n\tprivate void setupProgressIndicatorIfNeeded()\n\t{\n\t\tif (progressIndicator != null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (getChildrenUnmodifiable().size() == 1)\n\t\t{\n\t\t\tprogressIndicator = new ProgressIndicator();\n\t\t\tprogressIndicator.setVisible(false);\n\t\t\tgetChildren().add(progressIndicator);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthrow new IllegalStateException(\"Progress indicator is only supported if there's 1 children\");\n\t\t}\n\n\t\tdelayedAction = new DelayedAction(\n\t\t\t\t() -> showProgressIndicator(true),\n\t\t\t\t() -> showProgressIndicator(false),\n\t\t\t\tPROGRESS_SHOW_DELAY);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/ReadOnlyTextField.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.control.SeparatorMenuItem;\nimport javafx.scene.control.TextField;\n\nimport java.util.List;\nimport java.util.ResourceBundle;\n\n/**\n * A TextField that is used for read-only fields (like displaying some informative, yet important value). It features:\n * <p>\n * <ul>\n * <li>explanatory look\n * <li>automatic selection when clicking for easy cut &amp; pasting\n * <li>context menu to disable the selection\n * </ul>\n */\npublic class ReadOnlyTextField extends TextField\n{\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ReadOnlyTextField()\n\t{\n\t\tsuper();\n\t\tinit();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic ReadOnlyTextField(String text)\n\t{\n\t\tsuper(text);\n\t\tinit();\n\t}\n\n\tprivate void init()\n\t{\n\t\tUiUtils.setOnPrimaryMouseClicked(this, event -> selectAll());\n\t\tsetEditable(false);\n\n\t\tsetContextMenu(createContextMenu());\n\t}\n\n\tprivate ContextMenu createContextMenu()\n\t{\n\t\tvar contextMenu = new ContextMenu();\n\n\t\tcontextMenu.getItems().addAll(createDefaultMenuItems());\n\t\tvar deselect = new MenuItem(bundle.getString(\"deselect-all\"));\n\t\tdeselect.setOnAction(event -> deselect());\n\t\tcontextMenu.getItems().addAll(new SeparatorMenuItem(), deselect);\n\t\treturn contextMenu;\n\t}\n\n\tprivate List<MenuItem> createDefaultMenuItems()\n\t{\n\t\tvar copy = new MenuItem(bundle.getString(\"copy\"));\n\t\tcopy.setOnAction(event -> copy());\n\n\t\treturn List.of(copy);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/ResizeableImageView.java",
    "content": "package io.xeres.ui.custom;\n\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\n\n/**\n * Modified image class that allows unlimited up-scaling.\n */\npublic class ResizeableImageView extends AsyncImageView\n{\n\tprivate static final double MINIMUM_SIZE = 32.0;\n\n\tpublic ResizeableImageView()\n\t{\n\t\tsuper();\n\t\tsetPreserveRatio(false);\n\t}\n\n\t@Override\n\tpublic double minWidth(double height)\n\t{\n\t\treturn MINIMUM_SIZE;\n\t}\n\n\t@Override\n\tpublic double prefWidth(double height)\n\t{\n\t\tvar image = getImage();\n\t\tif (image == null)\n\t\t{\n\t\t\treturn minWidth(height);\n\t\t}\n\t\treturn image.getWidth();\n\t}\n\n\t@Override\n\tpublic double maxWidth(double height)\n\t{\n\t\treturn Double.MAX_VALUE;\n\t}\n\n\t@Override\n\tpublic double minHeight(double width)\n\t{\n\t\treturn MINIMUM_SIZE;\n\t}\n\n\t@Override\n\tpublic double prefHeight(double width)\n\t{\n\t\tvar image = getImage();\n\t\tif (image == null)\n\t\t{\n\t\t\treturn minHeight(width);\n\t\t}\n\t\treturn image.getHeight();\n\t}\n\n\t@Override\n\tpublic double maxHeight(double width)\n\t{\n\t\treturn Double.MAX_VALUE;\n\t}\n\n\t@Override\n\tpublic boolean isResizable()\n\t{\n\t\treturn true;\n\t}\n\n\t@Override\n\tpublic void resize(double width, double height)\n\t{\n\t\tsetFitWidth(width);\n\t\tsetFitHeight(height);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/StickerView.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.custom.event.StickerSelectedEvent;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.concurrent.Task;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.geometry.Insets;\nimport javafx.scene.control.*;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.layout.Pane;\nimport javafx.scene.layout.VBox;\nimport javafx.scene.text.TextFlow;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.FileInputStream;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.text.MessageFormat;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Stream;\n\npublic class StickerView extends VBox\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(StickerView.class);\n\n\tprivate static final int IMAGE_COLLECTION_WIDTH = 48;\n\tprivate static final int IMAGE_COLLECTION_HEIGHT = 48;\n\n\tprivate static final int IMAGE_WIDTH = 192;\n\tprivate static final int IMAGE_HEIGHT = 192;\n\n\tprivate static final Pattern PATTERN_ORDERED_NAME = Pattern.compile(\"^(\\\\d{1,3}\\\\.)(.*?)(\\\\.\\\\w{1,10})?$\");\n\n\t@FXML\n\tprivate TabPane tabPane;\n\n\tprivate final ResourceBundle bundle;\n\n\tpublic StickerView()\n\t{\n\t\tbundle = I18nUtils.getBundle();\n\n\t\tvar loader = new FXMLLoader(StickerView.class.getResource(\"/view/custom/sticker_view.fxml\"));\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic void loadStickers(Path localPath, Path userPath)\n\t{\n\t\tTask<List<StickerCollectionEntry>> task = new Task<>()\n\t\t{\n\t\t\t@Override\n\t\t\tprotected List<StickerCollectionEntry> call() throws Exception\n\t\t\t{\n\t\t\t\tList<StickerCollectionEntry> stickerCollections = new ArrayList<>();\n\n\t\t\t\tif (Files.isDirectory(localPath))\n\t\t\t\t{\n\t\t\t\t\ttry (var stream = Files.find(localPath, 1, (dirPath, bfa) -> bfa.isDirectory() && !dirPath.equals(localPath)))\n\t\t\t\t\t{\n\t\t\t\t\t\tstickerCollections.addAll(processStickers(stream));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (Files.isDirectory(userPath))\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Found sticker collections directory in {}\", userPath);\n\t\t\t\t\ttry (var stream = Files.find(userPath, 1, (dirPath, bfa) -> bfa.isDirectory() && !dirPath.equals(userPath)))\n\t\t\t\t\t{\n\t\t\t\t\t\tstickerCollections.addAll(processStickers(stream));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn stickerCollections.stream()\n\t\t\t\t\t\t.sorted(Comparator.comparing(StickerCollectionEntry::name))\n\t\t\t\t\t\t.toList();\n\t\t\t}\n\t\t};\n\t\ttask.setOnSucceeded(event -> {\n\t\t\t@SuppressWarnings(\"unchecked\") var stickers = (List<StickerCollectionEntry>) event.getSource().getValue();\n\n\t\t\tif (stickers.isEmpty())\n\t\t\t{\n\t\t\t\ttabPane.getTabs().add(new Tab(\"\", new Label(MessageFormat.format(bundle.getString(\"stickers.instructions\"), userPath))));\n\t\t\t}\n\n\t\t\ttabPane.getTabs().addAll(stickers.stream()\n\t\t\t\t\t.map(sticker -> {\n\t\t\t\t\t\tTab tab = null;\n\t\t\t\t\t\tif (sticker.image() != null && !sticker.image().isError())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttab = new Tab();\n\t\t\t\t\t\t\ttab.setTooltip(new Tooltip(buildStickerName(sticker.name())));\n\t\t\t\t\t\t\tvar imageView = new ImageView(sticker.image());\n\t\t\t\t\t\t\timageView.setPickOnBounds(true); // make transparent areas clickable\n\t\t\t\t\t\t\tImageViewUtils.limitMaximumImageSize(imageView, IMAGE_COLLECTION_WIDTH, IMAGE_COLLECTION_HEIGHT);\n\t\t\t\t\t\t\tImageViewUtils.disableOutputScaling(imageView, tabPane);\n\t\t\t\t\t\t\ttab.setGraphic(imageView);\n\t\t\t\t\t\t\ttab.setUserData(sticker.path());\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn tab;\n\t\t\t\t\t})\n\t\t\t\t\t.filter(Objects::nonNull)\n\t\t\t\t\t.toList());\n\n\t\t\tsetupTabSelection();\n\t\t});\n\t\tThread.ofVirtual().name(\"Stickers Collection Directory Loader\").start(task);\n\t}\n\n\tprivate List<StickerCollectionEntry> processStickers(Stream<Path> stream)\n\t{\n\t\treturn stream\n\t\t\t\t.map(filePath -> new StickerCollectionEntry(filePath.getFileName().toString(), filePath, getStickerMainImage(filePath)))\n\t\t\t\t.toList();\n\t}\n\n\tprivate String buildStickerName(String name)\n\t{\n\t\tvar matcher = PATTERN_ORDERED_NAME.matcher(name);\n\t\tif (matcher.matches())\n\t\t{\n\t\t\treturn matcher.group(2);\n\t\t}\n\t\treturn name;\n\t}\n\n\tprivate void setupTabSelection()\n\t{\n\t\tif (!tabPane.getTabs().isEmpty())\n\t\t{\n\t\t\tloadTab(tabPane.getSelectionModel().getSelectedIndex());\n\t\t}\n\t\ttabPane.getSelectionModel().selectedIndexProperty().addListener((_, _, newValue) -> loadTab(newValue.intValue()));\n\t}\n\n\tprivate void loadTab(int index)\n\t{\n\t\tvar tab = tabPane.getTabs().get(index);\n\n\t\tif (tab.getContent() == null)\n\t\t{\n\t\t\tvar path = (Path) tab.getUserData();\n\t\t\tvar textFlow = new TextFlow();\n\t\t\ttextFlow.setPrefWidth(600.0);\n\t\t\ttextFlow.setPadding(new Insets(8.0));\n\t\t\tUiUtils.setOnPrimaryMouseClicked(textFlow, event -> {\n\t\t\t\tif (event.getTarget() instanceof ImageView imageView)\n\t\t\t\t{\n\t\t\t\t\tfireEvent(new StickerSelectedEvent((Path) imageView.getUserData()));\n\t\t\t\t}\n\t\t\t});\n\t\t\tvar scrollPane = new ScrollPane(textFlow);\n\t\t\tscrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);\n\t\t\ttab.setContent(scrollPane);\n\n\t\t\tTask<Void> task = new Task<>()\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tprotected Void call() throws Exception\n\t\t\t\t{\n\t\t\t\t\tif (Files.isDirectory(path))\n\t\t\t\t\t{\n\t\t\t\t\t\ttry (var stream = Files.find(path, 1, (_, bfa) -> bfa.isRegularFile()))\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tstream\n\t\t\t\t\t\t\t\t\t.sorted(Comparator.comparing(filePath -> filePath.getFileName().toString()))\n\t\t\t\t\t\t\t\t\t.forEach(filePath -> {\n\t\t\t\t\t\t\t\t\t\tvar image = openImage(filePath);\n\t\t\t\t\t\t\t\t\t\tif (image != null && !image.isError())\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\t\t\t\t\t\t\t\tvar imageView = new ImageView(image);\n\t\t\t\t\t\t\t\t\t\t\t\timageView.setPickOnBounds(true); // make transparent areas clickable\n\t\t\t\t\t\t\t\t\t\t\t\tImageViewUtils.limitMaximumImageSize(imageView, IMAGE_WIDTH, IMAGE_HEIGHT);\n\t\t\t\t\t\t\t\t\t\t\t\tImageViewUtils.disableOutputScaling(imageView, tabPane);\n\t\t\t\t\t\t\t\t\t\t\t\timageView.setUserData(filePath);\n\t\t\t\t\t\t\t\t\t\t\t\timageView.getStyleClass().add(\"sticker-image\");\n\t\t\t\t\t\t\t\t\t\t\t\tTooltipUtils.install(imageView, buildStickerName(filePath.getFileName().toString()));\n\t\t\t\t\t\t\t\t\t\t\t\tvar pane = new Pane(imageView);\n\t\t\t\t\t\t\t\t\t\t\t\tpane.setPadding(new Insets(8.0));\n\t\t\t\t\t\t\t\t\t\t\t\ttextFlow.getChildren().add(pane);\n\t\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t};\n\t\t\tThread.ofVirtual().name(\"Stickers Collection Content Loader\").start(task);\n\t\t}\n\t}\n\n\tprivate static Image getStickerMainImage(Path directory)\n\t{\n\t\ttry (var stream = Files.find(directory, 1, (_, bfa) -> bfa.isRegularFile()))\n\t\t{\n\t\t\treturn stream\n\t\t\t\t\t.findFirst()\n\t\t\t\t\t//.map(path -> openImage(path, IMAGE_COLLECTION_WIDTH, IMAGE_COLLECTION_HEIGHT))\n\t\t\t\t\t.map(StickerView::openImage) // XXX: workaround for JavaFX's bug not handling scaling for images loaded with ImageIO (eg. WebP stickers)\n\t\t\t\t\t.orElse(null);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't get sticker main image from {}: {}\", directory, e.getMessage());\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t@SuppressWarnings(\"SameParameterValue\")\n\tprivate static Image openImage(Path path, int width, int height)\n\t{\n\t\ttry (var inputStream = new FileInputStream(path.toFile()))\n\t\t{\n\t\t\treturn new Image(inputStream, width, height, true, true);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.debug(\"Couldn't open image with specific size {}: {}\", path, e.getMessage());\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate static Image openImage(Path path)\n\t{\n\t\ttry (var inputStream = new FileInputStream(path.toFile()))\n\t\t{\n\t\t\treturn new Image(inputStream);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tlog.debug(\"Couldn't open image {}: {}\", path, e.getMessage());\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate record StickerCollectionEntry(String name, Path path, Image image)\n\t{\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/TypingNotificationView.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.ProgressIndicator;\nimport javafx.scene.layout.HBox;\n\nimport java.io.IOException;\n\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\n\npublic class TypingNotificationView extends HBox\n{\n\t@FXML\n\tprivate ProgressIndicator progressIndicator;\n\n\t@FXML\n\tprivate Label text;\n\n\t@FXML\n\tprivate WaveDotsView waveDotsView;\n\n\tpublic TypingNotificationView()\n\t{\n\t\tvar loader = new FXMLLoader(TypingNotificationView.class.getResource(\"/view/custom/typing_notification_view.fxml\"));\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tpublic void setText(String text)\n\t{\n\t\twaveDotsView.setVisible(!isEmpty(text));\n\t\tthis.text.setText(text);\n\t}\n\n\tpublic void setProgress(String text)\n\t{\n\t\twaveDotsView.setVisible(false);\n\t\tthis.text.setText(text);\n\t\tUiUtils.setPresent(progressIndicator);\n\t}\n\n\tpublic void stopProgress()\n\t{\n\t\tif (progressIndicator.isManaged())\n\t\t{\n\t\t\tUiUtils.setAbsent(progressIndicator);\n\t\t\ttext.setText(null);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/WaveDotsView.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport javafx.animation.Animation;\nimport javafx.animation.PauseTransition;\nimport javafx.animation.SequentialTransition;\nimport javafx.animation.TranslateTransition;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.shape.Circle;\nimport javafx.util.Duration;\n\nimport java.io.IOException;\n\npublic class WaveDotsView extends HBox\n{\n\t@FXML\n\tprivate Circle circle1;\n\n\t@FXML\n\tprivate Circle circle2;\n\n\t@FXML\n\tprivate Circle circle3;\n\n\tpublic WaveDotsView()\n\t{\n\t\tvar loader = new FXMLLoader(WaveDotsView.class.getResource(\"/view/custom/wave_dots_view.fxml\"));\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\t@FXML\n\tprivate void initialize()\n\t{\n\t\tvar t1 = createAnimation(circle1, Duration.millis(0));\n\t\tt1.play();\n\n\t\tvar t2 = createAnimation(circle2, Duration.millis(200));\n\t\tt2.play();\n\n\t\tvar t3 = createAnimation(circle3, Duration.millis(400));\n\t\tt3.play();\n\t}\n\n\tprivate static Animation createAnimation(Circle circle, Duration initialDelay)\n\t{\n\t\tvar translate = new TranslateTransition(Duration.millis(300), circle);\n\t\ttranslate.setToY(5.0f);\n\n\t\tvar pause = new PauseTransition(Duration.millis(300));\n\n\t\tvar sequence = new SequentialTransition(translate, pause);\n\t\tsequence.setAutoReverse(true);\n\t\tsequence.setCycleCount(Animation.INDEFINITE);\n\t\tsequence.setDelay(initialDelay);\n\t\treturn sequence;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/alias/AliasCell.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.alias;\n\nimport atlantafx.base.theme.Styles;\nimport io.xeres.ui.support.chat.AliasEntry;\nimport io.xeres.ui.support.util.TooltipUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.ListCell;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.layout.VBox;\nimport org.apache.commons.lang3.StringUtils;\n\npublic class AliasCell extends ListCell<AliasEntry>\n{\n\tprivate VBox vbox;\n\tprivate Label name;\n\tprivate Label required;\n\tprivate Label optional;\n\tprivate Label description;\n\n\t@Override\n\tprotected void updateItem(AliasEntry item, boolean empty)\n\t{\n\t\tsuper.updateItem(item, empty);\n\t\tsetGraphic(empty ? null : updateAlias(item));\n\t}\n\n\tprivate VBox updateAlias(AliasEntry entry)\n\t{\n\t\tif (vbox == null)\n\t\t{\n\t\t\tname = new Label();\n\t\t\trequired = new Label();\n\t\t\tTooltipUtils.install(required, \"Required\");\n\t\t\trequired.getStyleClass().add(Styles.ACCENT);\n\t\t\toptional = new Label();\n\t\t\tTooltipUtils.install(optional, \"Optional\");\n\t\t\toptional.getStyleClass().add(Styles.TEXT_SUBTLE);\n\t\t\tdescription = new Label(entry.description());\n\t\t\tdescription.getStyleClass().add(Styles.TEXT_SMALL);\n\t\t\tdescription.getStyleClass().add(Styles.TEXT_MUTED);\n\t\t\tvar hbox = new HBox(name, required, optional);\n\t\t\thbox.setSpacing(4);\n\t\t\tvbox = new VBox(hbox, description);\n\t\t}\n\t\tname.setText(\"/\" + entry.name());\n\t\trequired.setText(entry.required());\n\t\tUiUtils.setAbsent(required, StringUtils.isEmpty(entry.required()));\n\t\toptional.setText(entry.optional());\n\t\tUiUtils.setAbsent(optional, StringUtils.isEmpty(entry.optional()));\n\t\tdescription.setText(entry.description());\n\t\treturn vbox;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/alias/AliasView.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.alias;\n\nimport io.xeres.ui.support.chat.AliasEntry;\nimport javafx.application.Platform;\nimport javafx.collections.FXCollections;\nimport javafx.collections.transformation.FilteredList;\nimport javafx.fxml.FXML;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.control.ListView;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyEvent;\nimport javafx.scene.input.MouseButton;\nimport javafx.scene.input.MouseEvent;\nimport javafx.scene.layout.VBox;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Locale;\n\nclass AliasView extends VBox\n{\n\tinterface OnActionListener\n\t{\n\t\tvoid complete(String action);\n\n\t\tvoid cancel();\n\t}\n\n\t@FXML\n\tprivate ListView<AliasEntry> aliasList;\n\n\tprivate OnActionListener onActionListener;\n\n\tprivate FilteredList<AliasEntry> filteredList;\n\n\tpublic AliasView()\n\t{\n\t\tvar loader = new FXMLLoader(AliasView.class.getResource(\"/view/custom/alias_view.fxml\"));\n\t\tloader.setRoot(this);\n\t\tloader.setController(this);\n\n\t\ttry\n\t\t{\n\t\t\tloader.load();\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\n\t\taliasList.setCellFactory(_ -> new AliasCell());\n\t\taddEventFilter(KeyEvent.KEY_PRESSED, event -> {\n\t\t\tif (event.getCode() == KeyCode.ENTER || event.getCode() == KeyCode.TAB)\n\t\t\t{\n\t\t\t\taction();\n\t\t\t}\n\t\t\telse if (event.getCode() == KeyCode.ESCAPE)\n\t\t\t{\n\t\t\t\tif (onActionListener != null)\n\t\t\t\t{\n\t\t\t\t\tonActionListener.cancel();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\taddEventFilter(MouseEvent.MOUSE_RELEASED, event -> {\n\t\t\tif (event.getButton() == MouseButton.PRIMARY)\n\t\t\t{\n\t\t\t\taction();\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void action()\n\t{\n\t\tif (onActionListener != null)\n\t\t{\n\t\t\tvar alias = aliasList.getSelectionModel().getSelectedItem();\n\t\t\tif (alias != null)\n\t\t\t{\n\t\t\t\tonActionListener.complete(getAliasString(alias));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (filteredList.size() == 1)\n\t\t\t\t{\n\t\t\t\t\talias = filteredList.getFirst();\n\t\t\t\t\tonActionListener.complete(getAliasString(alias));\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tonActionListener.complete(null);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static String getAliasString(AliasEntry alias)\n\t{\n\t\treturn \"/\" + alias.name() + ((alias.required() != null || alias.optional() != null) ? \" \" : \"\");\n\t}\n\n\tpublic void setListener(OnActionListener onActionListener)\n\t{\n\t\tthis.onActionListener = onActionListener;\n\t}\n\n\tpublic void setAliasList(List<AliasEntry> entries)\n\t{\n\t\tfilteredList = new FilteredList<>(FXCollections.observableArrayList(entries), _ -> true);\n\t\taliasList.setItems(filteredList);\n\n\t\tif (!entries.isEmpty())\n\t\t{\n\t\t\taliasList.getSelectionModel().selectFirst();\n\t\t}\n\t\taliasList.getSelectionModel().selectedItemProperty().addListener((_, oldValue, newValue) -> {\n\t\t\tif (newValue == null && !aliasList.getItems().isEmpty())\n\t\t\t{\n\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\t// Try to reselect the old value if it still exists\n\t\t\t\t\tif (oldValue != null && aliasList.getItems().contains(oldValue))\n\t\t\t\t\t{\n\t\t\t\t\t\taliasList.getSelectionModel().select(oldValue);\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\t// Otherwise, select the first\n\t\t\t\t\t\taliasList.getSelectionModel().selectFirst();\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void setFilter(String text)\n\t{\n\t\tfilteredList.setPredicate(aliasEntry -> {\n\t\t\tif (StringUtils.isEmpty(text))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\tvar textLw = text.toLowerCase(Locale.ENGLISH);\n\t\t\tvar aliasLw = (\"/\" + aliasEntry.name()).toLowerCase(Locale.ROOT);\n\n\t\t\treturn aliasLw.contains(textLw) || textLw.contains(aliasLw);\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/alias/PopupAlias.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.alias;\n\nimport io.xeres.ui.support.chat.ChatCommand;\nimport javafx.geometry.Bounds;\nimport javafx.stage.Popup;\nimport javafx.stage.PopupWindow;\n\nimport java.util.function.Consumer;\n\npublic class PopupAlias extends Popup\n{\n\tprivate final AliasView aliasView;\n\n\tpublic PopupAlias(Bounds bounds, Consumer<String> complete)\n\t{\n\t\tsuper();\n\n\t\taliasView = new AliasView();\n\t\tsetAnchorX(bounds.getMinX());\n\t\tsetAnchorY(bounds.getMinY());\n\t\tsetAnchorLocation(PopupWindow.AnchorLocation.CONTENT_BOTTOM_LEFT);\n\n\t\tgetContent().add(aliasView);\n\t\tsetAutoHide(true);\n\n\t\taliasView.setAliasList(ChatCommand.ALIASES);\n\n\t\taliasView.setListener(new AliasView.OnActionListener()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void complete(String action)\n\t\t\t{\n\t\t\t\tif (complete != null)\n\t\t\t\t{\n\t\t\t\t\tcomplete.accept(action);\n\t\t\t\t\thide();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic void cancel()\n\t\t\t{\n\t\t\t\thide();\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void setFilter(String text)\n\t{\n\t\taliasView.setFilter(text);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/asyncimage/AsyncImageView.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.asyncimage;\n\nimport javafx.application.Platform;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport org.apache.commons.lang3.ArrayUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.lang.ref.WeakReference;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.nio.file.Files;\nimport java.nio.file.Paths;\nimport java.util.LinkedList;\nimport java.util.Objects;\nimport java.util.Queue;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.FutureTask;\nimport java.util.function.Function;\n\n/**\n * An {@link ImageView} subclass that can load images asynchronously like {@link Image} does with\n * its argument constructor. The difference is that this class can use any function for doing so\n * and not just load from a public URL.\n * <p>\n * Important: always use {@link #updateImage} instead of {@link #setImage} (which is final).\n */\npublic class AsyncImageView extends ImageView\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(AsyncImageView.class);\n\n\tprivate static final int MAX_RUNNING_TASKS = 4; // same default values as Image's background task loader\n\tprivate static int runningTasks;\n\tprivate static final Queue<LoaderTask> pendingTasks = new LinkedList<>();\n\n\tprivate WeakReference<LoaderTask> taskReference;\n\tprivate String url;\n\tprivate Function<String, byte[]> loader;\n\tprivate Runnable onSuccess;\n\tprivate ImageCache imageCache;\n\tprivate boolean canCallSetImage;\n\n\tpublic AsyncImageView()\n\t{\n\t\tthis(null, null);\n\t}\n\n\tpublic AsyncImageView(Function<String, byte[]> loader)\n\t{\n\t\tthis(loader, null);\n\t}\n\n\tpublic AsyncImageView(Function<String, byte[]> loader, ImageCache imageCache)\n\t{\n\t\tsuper();\n\t\tsetLoader(loader);\n\t\tsetImageCache(imageCache);\n\t\t// setImage() is final and the listener is called *after* the\n\t\t// property is set (and acted upon by ImageView) so this is the\n\t\t// next best thing we can do to \"override\" it.\n\t\timageProperty().addListener((_, _, _) -> {\n\t\t\tif (!canCallSetImage)\n\t\t\t{\n\t\t\t\tvar sb = new StringBuilder(\"setImage() has been called on AsyncImageView! This can cause problems like images being empty or wrong. Use updateImage() instead!\\n\");\n\t\t\t\tvar trace = Thread.currentThread().getStackTrace();\n\t\t\t\tfor (var stackTraceElement : trace)\n\t\t\t\t{\n\t\t\t\t\tsb.append(\"\\tat \").append(stackTraceElement).append(\"\\n\");\n\t\t\t\t}\n\t\t\t\tlog.error(sb.toString());\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Sets the url to load. Also accepts file: urls (in that case the loader is bypassed).\n\t *\n\t * @param url the url to load\n\t */\n\tpublic void setUrl(String url)\n\t{\n\t\tif (StringUtils.isBlank(url))\n\t\t{\n\t\t\tcancel();\n\t\t\tthis.url = null;\n\t\t\tupdateImage(null);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (getImage() != null)\n\t\t\t{\n\t\t\t\tif (Objects.equals(url, this.url))\n\t\t\t\t{\n\t\t\t\t\t// Do not load again, if it's already loaded/being loaded.\n\t\t\t\t\tif (onSuccess != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tonSuccess.run();\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tupdateImage(null);\n\t\t\t}\n\t\t\tthis.url = url;\n\t\t\tLoaderTask.loadImage(this, url, loader, onSuccess, imageCache);\n\t\t}\n\t}\n\n\t/**\n\t * Sets the image. <b>HAS</b> to be used instead of {@link #setImage} otherwise there\n\t * might be side effects like missing images or wrong image.\n\t *\n\t * @param image the image, can be null\n\t */\n\tpublic void updateImage(Image image)\n\t{\n\t\tsetLoaderTask(null);\n\t\tcanCallSetImage = true;\n\t\tsetImage(image);\n\t\tcanCallSetImage = false;\n\t}\n\n\t/**\n\t * Sets the loader. This is needed to load an url asynchronously.\n\t *\n\t * @param loader the loader\n\t */\n\tpublic void setLoader(Function<String, byte[]> loader)\n\t{\n\t\tthis.loader = loader;\n\t}\n\n\t/**\n\t * Checks if a loader has been set. This is useful to reporting missing API usage.\n\t *\n\t * @return true if a loader has been set\n\t */\n\tpublic boolean hasLoader()\n\t{\n\t\treturn loader != null;\n\t}\n\n\tpublic void setOnSuccess(Runnable onSuccess)\n\t{\n\t\tthis.onSuccess = onSuccess;\n\t}\n\n\tpublic void setImageCache(ImageCache imageCache)\n\t{\n\t\tthis.imageCache = imageCache;\n\t}\n\n\tpublic void cancel()\n\t{\n\t\tvar task = getLoaderTask();\n\t\tif (task != null)\n\t\t{\n\t\t\ttask.cancel();\n\t\t}\n\t}\n\n\tprivate void setLoaderTask(LoaderTask task)\n\t{\n\t\tif (task == null)\n\t\t{\n\t\t\tif (taskReference != null)\n\t\t\t{\n\t\t\t\ttaskReference.clear();\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttaskReference = new WeakReference<>(task);\n\t\t}\n\t}\n\n\tprivate LoaderTask getLoaderTask()\n\t{\n\t\tif (taskReference != null)\n\t\t{\n\t\t\treturn taskReference.get();\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate static final class LoaderTask\n\t{\n\t\tprivate static final ExecutorService BACKGROUND_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();\n\n\t\tprivate final WeakReference<AsyncImageView> imageViewReference;\n\t\tprivate final String url;\n\t\tprivate final Runnable onSuccess;\n\t\tprivate final ImageCache imageCache;\n\n\t\tprivate final FutureTask<byte[]> future;\n\n\t\tprivate static void loadImage(AsyncImageView imageView, String url, Function<String, byte[]> loader, Runnable onSuccess, ImageCache imageCache)\n\t\t{\n\t\t\tif (useFromCache(url, imageView, imageCache))\n\t\t\t{\n\t\t\t\tif (onSuccess != null)\n\t\t\t\t{\n\t\t\t\t\tonSuccess.run();\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (canDoWork(url, imageView))\n\t\t\t{\n\t\t\t\tif (loader != null)\n\t\t\t\t{\n\t\t\t\t\tvar task = new LoaderTask(imageView, url, loader, onSuccess, imageCache);\n\t\t\t\t\timageView.setLoaderTask(task);\n\t\t\t\t\trunTask(task);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog.warn(\"No loader has been set for image url {}, cannot load image\", url);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tprivate static boolean canDoWork(String url, AsyncImageView imageView)\n\t\t{\n\t\t\tvar task = getLoaderTask(imageView);\n\n\t\t\tif (task != null)\n\t\t\t{\n\t\t\t\tif (url.equals(task.url))\n\t\t\t\t{\n\t\t\t\t\t// Same work already in progress\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\ttask.cancel();\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t}\n\n\t\tprivate static boolean useFromCache(String url, AsyncImageView imageView, ImageCache imageCache)\n\t\t{\n\t\t\tif (imageCache != null)\n\t\t\t{\n\t\t\t\tvar image = imageCache.getImage(url);\n\t\t\t\tif (image != null)\n\t\t\t\t{\n\t\t\t\t\timageView.updateImage(image);\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\tprivate LoaderTask(AsyncImageView asyncImageView, String url, Function<String, byte[]> loader, Runnable onSuccess, ImageCache imageCache)\n\t\t{\n\t\t\timageViewReference = new WeakReference<>(asyncImageView);\n\t\t\tthis.url = url;\n\t\t\tthis.onSuccess = onSuccess;\n\t\t\tthis.imageCache = imageCache;\n\t\t\tfuture = new FutureTask<>(() -> isFileUri(url) ? loadFile(url) : loader.apply(url))\n\t\t\t{\n\t\t\t\t@Override\n\t\t\t\tprotected void done()\n\t\t\t\t{\n\t\t\t\t\tif (future.isCancelled())\n\t\t\t\t\t{\n\t\t\t\t\t\tonCancel();\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\ttry\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar data = future.get();\n\t\t\t\t\t\t\tif (ArrayUtils.isEmpty(data))\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tonFailure();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Image can apparently decode outside the main thread, which is exactly what we need.\n\t\t\t\t\t\t\t\tonCompletion(decodeImage(data));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcatch (InterruptedException _)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tonCancel();\n\t\t\t\t\t\t\tThread.currentThread().interrupt();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcatch (ExecutionException e)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tonException(e);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t};\n\t\t}\n\n\t\tprivate static boolean isFileUri(String url)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar uri = new URI(url);\n\t\t\t\treturn \"file\".equalsIgnoreCase(uri.getScheme());\n\t\t\t}\n\t\t\tcatch (URISyntaxException _)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\tprivate static byte[] loadFile(String url)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar path = Paths.get(new URI(url));\n\t\t\t\treturn Files.readAllBytes(path);\n\t\t\t}\n\t\t\tcatch (IOException | URISyntaxException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t}\n\n\t\tprivate Image decodeImage(byte[] data)\n\t\t{\n\t\t\treturn new Image(new ByteArrayInputStream(data));\n\t\t}\n\n\t\tprivate void onCompletion(Image image)\n\t\t{\n\t\t\tif (!future.isCancelled())\n\t\t\t{\n\t\t\t\tPlatform.runLater(() -> {\n\t\t\t\t\tif (imageCache != null)\n\t\t\t\t\t{\n\t\t\t\t\t\timageCache.putImage(url, image);\n\t\t\t\t\t}\n\t\t\t\t\tvar imageView = imageViewReference.get();\n\t\t\t\t\trunIfSameTask(imageView, () -> {\n\t\t\t\t\t\tassert imageView != null;\n\t\t\t\t\t\timageView.updateImage(image);\n\t\t\t\t\t\tif (onSuccess != null)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tonSuccess.run();\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t\tcycleTasks();\n\t\t\t}\n\t\t}\n\n\t\tprivate void onFailure()\n\t\t{\n\t\t\tcycleTasks();\n\t\t}\n\n\t\tprivate void onCancel()\n\t\t{\n\t\t\tcycleTasks();\n\t\t}\n\n\t\tprivate void onException(Exception e)\n\t\t{\n\t\t\tlog.error(\"Couldn't load image: {}\", e.getMessage());\n\t\t\tcycleTasks();\n\t\t}\n\n\t\tprivate void runIfSameTask(AsyncImageView imageView, Runnable runnable)\n\t\t{\n\t\t\tvar task = getLoaderTask(imageView);\n\t\t\tif (this == task)\n\t\t\t{\n\t\t\t\trunnable.run();\n\t\t\t}\n\t\t}\n\n\t\tprivate void start()\n\t\t{\n\t\t\tBACKGROUND_EXECUTOR.execute(future);\n\t\t}\n\n\t\tprivate void cancel()\n\t\t{\n\t\t\tfuture.cancel(true);\n\t\t}\n\n\t\tprivate static LoaderTask getLoaderTask(AsyncImageView imageView)\n\t\t{\n\t\t\tif (imageView != null)\n\t\t\t{\n\t\t\t\treturn imageView.getLoaderTask();\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\n\t\tprivate static void runTask(LoaderTask task)\n\t\t{\n\t\t\tsynchronized (pendingTasks)\n\t\t\t{\n\t\t\t\tif (runningTasks >= MAX_RUNNING_TASKS)\n\t\t\t\t{\n\t\t\t\t\tpendingTasks.offer(task);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\trunningTasks++;\n\t\t\t\t\ttask.start();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tprivate static void cycleTasks()\n\t\t{\n\t\t\tsynchronized (pendingTasks)\n\t\t\t{\n\t\t\t\trunningTasks--;\n\t\t\t\tvar nextTask = pendingTasks.poll();\n\t\t\t\tif (nextTask != null)\n\t\t\t\t{\n\t\t\t\t\trunningTasks++;\n\t\t\t\t\tnextTask.start();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/asyncimage/ContactImageView.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.asyncimage;\n\nimport javafx.application.ConditionalFeature;\nimport javafx.application.Platform;\nimport javafx.scene.effect.DropShadow;\nimport javafx.scene.layout.StackPane;\nimport javafx.scene.paint.Color;\nimport javafx.scene.paint.ImagePattern;\nimport javafx.scene.shape.Circle;\n\nimport java.util.function.Function;\n\n/**\n * A round image with subtle shadows.\n */\npublic class ContactImageView extends StackPane\n{\n\tprivate final Circle circle;\n\tprivate final AsyncImageView asyncImageView;\n\n\tpublic ContactImageView(Function<String, byte[]> loader, ImageCache imageCache, int size)\n\t{\n\t\tsuper();\n\n\t\tcircle = new Circle((double) size / 2);\n\t\tcircle.setVisible(false);\n\t\tasyncImageView = new AsyncImageView(loader, imageCache);\n\t\tasyncImageView.setFitWidth(size);\n\t\tasyncImageView.setFitHeight(size);\n\t\tasyncImageView.setVisible(false);\n\t\tasyncImageView.setOnSuccess(() -> {\n\t\t\tcircle.setFill(new ImagePattern(asyncImageView.getImage()));\n\t\t\tcircle.setVisible(true);\n\t\t});\n\t\tif (Platform.isSupported(ConditionalFeature.EFFECT))\n\t\t{\n\t\t\tcircle.setEffect(new DropShadow((double) size / 8, Color.rgb(0, 0, 0, 0.7)));\n\t\t}\n\t\tgetChildren().addAll(circle, asyncImageView);\n\t}\n\n\tpublic void setUrl(String url)\n\t{\n\t\tcircle.setVisible(false);\n\t\tasyncImageView.setUrl(url);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/asyncimage/ImageCache.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.asyncimage;\n\nimport javafx.scene.image.Image;\n\npublic interface ImageCache\n{\n\tImage getImage(String url);\n\n\tvoid putImage(String url, Image image);\n\n\tvoid evictImage(String url);\n\n\tvoid evictAllImages();\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/asyncimage/PlaceholderImageView.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.asyncimage;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.beans.NamedArg;\nimport javafx.beans.property.ObjectProperty;\nimport javafx.scene.image.Image;\nimport javafx.scene.layout.StackPane;\nimport org.kordamp.ikonli.javafx.FontIcon;\n\nimport java.util.function.Function;\n\n/**\n * An AsyncImageView subclass that provides a default image when there's\n * nothing to show.\n */\npublic class PlaceholderImageView extends StackPane\n{\n\tprivate final AsyncImageView asyncImageView;\n\tprivate String iconLiteral;\n\tprivate FontIcon defaultIcon;\n\n\tprivate Double fitWidth;\n\tprivate Double fitHeight;\n\tprivate Boolean autoResize;\n\n\tpublic PlaceholderImageView(Function<String, byte[]> loader, String iconLiteral, ImageCache imageCache)\n\t{\n\t\tsuper();\n\n\t\tthis.iconLiteral = iconLiteral;\n\t\tasyncImageView = new AsyncImageView(loader, imageCache)\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void updateImage(Image image)\n\t\t\t{\n\t\t\t\tsetImageOrDefault(image);\n\t\t\t\tsuper.updateImage(image);\n\t\t\t}\n\t\t};\n\t\tgetChildren().add(asyncImageView);\n\t}\n\n\t// For FXML\n\t@SuppressWarnings(\"unused\")\n\tpublic PlaceholderImageView(@NamedArg(value = \"fitWidth\") Double fitWidth, @NamedArg(value = \"fitHeight\") Double fitHeight, @NamedArg(value = \"autoResize\") Boolean autoResize)\n\t{\n\t\tsuper();\n\n\t\tthis.fitWidth = fitWidth;\n\t\tthis.fitHeight = fitHeight;\n\t\tthis.autoResize = autoResize;\n\n\t\tasyncImageView = new AsyncImageView()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void updateImage(Image image)\n\t\t\t{\n\t\t\t\tsetImageOrDefault(image);\n\t\t\t\tsuper.updateImage(image);\n\t\t\t}\n\t\t};\n\t\tgetChildren().add(asyncImageView);\n\n\t\tinitialize();\n\t}\n\n\tprivate void initialize()\n\t{\n\t\tif (fitWidth != null && fitWidth != 0)\n\t\t{\n\t\t\tsetFitWidth(fitWidth);\n\t\t}\n\t\tif (fitHeight != null && fitHeight != 0)\n\t\t{\n\t\t\tsetFitHeight(fitHeight);\n\t\t}\n\t\tif (autoResize != null)\n\t\t{\n\t\t\tsetPreserveRatio(autoResize);\n\t\t}\n\t\tupdateDimensions();\n\t}\n\n\tpublic void setIconLiteral(String iconLiteral)\n\t{\n\t\tthis.iconLiteral = iconLiteral;\n\t}\n\n\tpublic void setLoader(Function<String, byte[]> loader)\n\t{\n\t\tasyncImageView.setLoader(loader);\n\t}\n\n\tpublic void setImageCache(ImageCache imageCache)\n\t{\n\t\tasyncImageView.setImageCache(imageCache);\n\t}\n\n\tpublic ObjectProperty<Image> imageProperty()\n\t{\n\t\treturn asyncImageView.imageProperty();\n\t}\n\n\tpublic void updateImage(Image image)\n\t{\n\t\tasyncImageView.updateImage(image);\n\t}\n\n\tpublic void setFitWidth(double value)\n\t{\n\t\tfitWidth = value;\n\t\tasyncImageView.setFitWidth(value);\n\t}\n\n\tpublic void setFitHeight(double value)\n\t{\n\t\tfitHeight = value;\n\t\tasyncImageView.setFitHeight(value);\n\t}\n\n\tpublic void setPreserveRatio(boolean value)\n\t{\n\t\tautoResize = value;\n\t\tasyncImageView.setPreserveRatio(value);\n\t}\n\n\tpublic void setUrl(String url)\n\t{\n\t\tupdateDimensions();\n\t\tasyncImageView.setUrl(url);\n\t}\n\n\tprivate void updateDimensions()\n\t{\n\t\tif (autoResize == null || !autoResize)\n\t\t{\n\t\t\tvar sizeSet = false;\n\t\t\tif (fitWidth != null && fitWidth != 0)\n\t\t\t{\n\t\t\t\tsetMinWidth(fitWidth);\n\t\t\t\tsizeSet = true;\n\t\t\t}\n\t\t\tif (fitHeight != null && fitHeight != 0)\n\t\t\t{\n\t\t\t\tsetMinHeight(fitHeight);\n\t\t\t\tsizeSet = true;\n\t\t\t}\n\t\t\tif (sizeSet)\n\t\t\t{\n\t\t\t\tasyncImageView.setPreserveRatio(true);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void setImageOrDefault(Image image)\n\t{\n\t\tif (image == null)\n\t\t{\n\t\t\tshowDefault();\n\t\t}\n\t\telse\n\t\t{\n\t\t\thideDefault();\n\t\t}\n\t}\n\n\tpublic void showDefault()\n\t{\n\t\tupdateDimensions();\n\t\tif (StringUtils.isBlank(iconLiteral))\n\t\t{\n\t\t\treturn; // No default to show\n\t\t}\n\n\t\tif (defaultIcon == null)\n\t\t{\n\t\t\tdefaultIcon = new FontIcon(iconLiteral);\n\t\t\tgetChildren().add(defaultIcon);\n\t\t}\n\n\t\tvar minSize = (int) Math.min(asyncImageView.getFitWidth(), asyncImageView.getFitHeight());\n\t\tif (minSize > 0)\n\t\t{\n\t\t\tUiUtils.setIconSize(defaultIcon, minSize);\n\t\t}\n\t\tUiUtils.setPresent(defaultIcon);\n\t}\n\n\tpublic void hideDefault()\n\t{\n\t\tupdateDimensions();\n\t\tif (defaultIcon != null)\n\t\t{\n\t\t\tUiUtils.setAbsent(defaultIcon);\n\t\t}\n\t}\n\n\tpublic Image getImage()\n\t{\n\t\treturn asyncImageView.getImage();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/event/FileSelectedEvent.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.event;\n\nimport javafx.event.Event;\nimport javafx.event.EventType;\n\nimport java.io.File;\nimport java.io.Serial;\n\npublic class FileSelectedEvent extends Event\n{\n\t@Serial\n\tprivate static final long serialVersionUID = -3716226621770176324L;\n\n\tpublic static final EventType<FileSelectedEvent> FILE_SELECTED = new EventType<>(ANY, \"FILE_SELECTED\");\n\n\tprivate final File file;\n\n\tpublic FileSelectedEvent(File file)\n\t{\n\t\tsuper(FILE_SELECTED);\n\t\tthis.file = file;\n\t}\n\n\tpublic File getFile()\n\t{\n\t\treturn file;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/event/ImageSelectedEvent.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.event;\n\nimport javafx.event.Event;\nimport javafx.event.EventType;\n\nimport java.io.File;\nimport java.io.Serial;\n\npublic class ImageSelectedEvent extends Event\n{\n\t@Serial\n\tprivate static final long serialVersionUID = 3786821529525777622L;\n\n\tpublic static final EventType<ImageSelectedEvent> IMAGE_SELECTED = new EventType<>(ANY, \"IMAGE_SELECTED\");\n\n\tprivate final File file;\n\n\tpublic ImageSelectedEvent(File file)\n\t{\n\t\tsuper(IMAGE_SELECTED);\n\t\tthis.file = file;\n\t}\n\n\tpublic File getFile()\n\t{\n\t\treturn file;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/event/StickerSelectedEvent.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.event;\n\nimport javafx.event.Event;\nimport javafx.event.EventType;\n\nimport java.io.Serial;\nimport java.nio.file.Path;\n\npublic class StickerSelectedEvent extends Event\n{\n\t@Serial\n\tprivate static final long serialVersionUID = -1377318297476370274L;\n\n\tpublic static final EventType<StickerSelectedEvent> STICKER_SELECTED = new EventType<>(ANY, \"STICKER_SELECTED\");\n\n\tprivate final transient Path path;\n\n\tpublic StickerSelectedEvent(Path path)\n\t{\n\t\tsuper(STICKER_SELECTED);\n\t\tthis.path = path;\n\t}\n\n\tpublic Path getPath()\n\t{\n\t\treturn path;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/led/LedControl.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.led;\n\nimport javafx.beans.property.BooleanProperty;\nimport javafx.beans.property.BooleanPropertyBase;\nimport javafx.beans.property.ObjectProperty;\nimport javafx.css.*;\nimport javafx.scene.control.Control;\nimport javafx.scene.control.Skin;\nimport javafx.scene.paint.Color;\n\nimport java.util.List;\n\n/**\n * A LED class. Strongly inspired from Gerrit Grunwald's <a href=\"https://github.com/HanSolo/JavaFXCustomControls\">JavaFXCustomControls</a>.\n */\npublic class LedControl extends Control\n{\n\tprivate static final StyleablePropertyFactory<LedControl> FACTORY = new StyleablePropertyFactory<>(Control.getClassCssMetaData());\n\n\t// CSS pseudo class\n\tprivate static final PseudoClass ON_PSEUDO_CLASS = PseudoClass.getPseudoClass(\"on\");\n\tprivate final BooleanProperty state;\n\n\t// CSS styleable property\n\tprivate static final CssMetaData<LedControl, Color> COLOR = FACTORY.createColorCssMetaData(\"-color\", ledControl -> ledControl.color, Color.GREEN, false);\n\tprivate final StyleableProperty<Color> color;\n\n\tpublic LedControl()\n\t{\n\t\tgetStyleClass().add(\"led-control\");\n\n\t\tstate = new BooleanPropertyBase(false)\n\t\t{\n\t\t\t@Override\n\t\t\tprotected void invalidated()\n\t\t\t{\n\t\t\t\tpseudoClassStateChanged(ON_PSEUDO_CLASS, get());\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic Object getBean()\n\t\t\t{\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t@Override\n\t\t\tpublic String getName()\n\t\t\t{\n\t\t\t\treturn \"state\";\n\t\t\t}\n\t\t};\n\n\t\tcolor = new SimpleStyleableObjectProperty<>(COLOR, this, \"color\");\n\t}\n\n\tpublic boolean hasState()\n\t{\n\t\treturn state.get();\n\t}\n\n\tpublic void setState(boolean state)\n\t{\n\t\tthis.state.set(state);\n\t}\n\n\tpublic BooleanProperty stateProperty()\n\t{\n\t\treturn state;\n\t}\n\n\tpublic Color getColor()\n\t{\n\t\treturn color.getValue();\n\t}\n\n\tpublic void setStatus(LedStatus ledStatus)\n\t{\n\t\tswitch (ledStatus)\n\t\t{\n\t\t\tcase OK -> setStatusClass(\"led-status-ok\");\n\t\t\tcase WARNING -> setStatusClass(\"led-status-warning\");\n\t\t\tcase ERROR -> setStatusClass(\"led-status-error\");\n\t\t}\n\t}\n\n\tprivate void setStatusClass(String className)\n\t{\n\t\tgetStyleClass().removeAll(\"led-status-ok\", \"led-status-warning\", \"led-status-error\");\n\t\tgetStyleClass().add(className);\n\t}\n\n\t@SuppressWarnings(\"unchecked\")\n\tpublic ObjectProperty<Color> colorProperty()\n\t{\n\t\treturn (ObjectProperty<Color>) color;\n\t}\n\n\t@Override\n\tprotected Skin<?> createDefaultSkin()\n\t{\n\t\treturn new LedSkin(this);\n\t}\n\n\t@Override\n\tprotected List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData()\n\t{\n\t\treturn FACTORY.getCssMetaData();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/led/LedSkin.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.led;\n\nimport javafx.beans.InvalidationListener;\nimport javafx.scene.control.Skin;\nimport javafx.scene.control.SkinBase;\nimport javafx.scene.effect.BlurType;\nimport javafx.scene.effect.DropShadow;\nimport javafx.scene.effect.InnerShadow;\nimport javafx.scene.layout.Region;\nimport javafx.scene.paint.Color;\n\n/**\n * A LED class. Strongly inspired from Gerrit Grunwald's <a href=\"https://github.com/HanSolo/JavaFXCustomControls\">JavaFXCustomControls</a>.\n */\npublic class LedSkin extends SkinBase<LedControl> implements Skin<LedControl>\n{\n\tprivate static final double PREFERRED_WIDTH = 16;\n\tprivate static final double PREFERRED_HEIGHT = 16;\n\n\tprivate static final double MINIMUM_WIDTH = 8;\n\tprivate static final double MINIMUM_HEIGHT = 8;\n\n\tprivate static final double MAXIMUM_WIDTH = 1024;\n\tprivate static final double MAXIMUM_HEIGHT = 1024;\n\n\tpublic static final String RESIZE_PROPERTY = \"RESIZE\";\n\tpublic static final String COLOR_PROPERTY = \"COLOR\";\n\tpublic static final String STATE_PROPERTY = \"STATE\";\n\n\tprivate Region frame;\n\tprivate Region main;\n\tprivate Region highlight;\n\n\tprivate InnerShadow innerShadow;\n\tprivate DropShadow glow;\n\n\tprivate LedControl control;\n\n\tprivate final InvalidationListener sizeListener;\n\tprivate final InvalidationListener colorListener;\n\tprivate final InvalidationListener stateListener;\n\n\tpublic LedSkin(LedControl control)\n\t{\n\t\tsuper(control);\n\t\tthis.control = control;\n\t\tsizeListener = observable -> handleControlPropertyChanged(RESIZE_PROPERTY);\n\t\tcolorListener = observable -> handleControlPropertyChanged(COLOR_PROPERTY);\n\t\tstateListener = observable -> handleControlPropertyChanged(STATE_PROPERTY);\n\t\tinitGraphics();\n\t\tregisterListeners();\n\t}\n\n\tprivate void initGraphics()\n\t{\n\t\tif (Double.compare(control.getPrefWidth(), 0.0) <= 0 || Double.compare(control.getPrefHeight(), 0.0) <= 0 ||\n\t\t\t\tDouble.compare(control.getWidth(), 0.0) <= 0 || Double.compare(control.getHeight(), 0.0) <= 0)\n\t\t{\n\t\t\tif (control.getPrefWidth() > 0 && control.getPrefHeight() > 0)\n\t\t\t{\n\t\t\t\tcontrol.setPrefSize(control.getPrefWidth(), control.getPrefHeight());\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontrol.setPrefSize(PREFERRED_WIDTH, PREFERRED_HEIGHT);\n\t\t\t}\n\t\t}\n\n\t\tframe = new Region();\n\t\tframe.getStyleClass().setAll(\"frame\");\n\n\t\tmain = new Region();\n\t\tmain.getStyleClass().setAll(\"main\");\n\n\t\tinnerShadow = new InnerShadow(BlurType.TWO_PASS_BOX, Color.rgb(0, 0, 0, 0.65), 8, 0, 0, 0);\n\n\t\tglow = new DropShadow(BlurType.TWO_PASS_BOX, control.getColor(), 20, 0, 0, 0);\n\t\tglow.setInput(innerShadow);\n\n\t\thighlight = new Region();\n\t\thighlight.getStyleClass().setAll(\"highlight\");\n\n\t\tgetChildren().addAll(frame, main, highlight);\n\t}\n\n\tprivate void registerListeners()\n\t{\n\t\tcontrol.widthProperty().addListener(sizeListener);\n\t\tcontrol.heightProperty().addListener(sizeListener);\n\t\tcontrol.colorProperty().addListener(colorListener);\n\t\tcontrol.stateProperty().addListener(stateListener);\n\t}\n\n\t@Override\n\tprotected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset)\n\t{\n\t\treturn MINIMUM_WIDTH;\n\t}\n\n\t@Override\n\tprotected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset)\n\t{\n\t\treturn MINIMUM_HEIGHT;\n\t}\n\n\t@Override\n\tprotected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset)\n\t{\n\t\treturn MAXIMUM_WIDTH;\n\t}\n\n\t@Override\n\tprotected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset)\n\t{\n\t\treturn MAXIMUM_HEIGHT;\n\t}\n\n\tprotected void handleControlPropertyChanged(String property)\n\t{\n\t\tif (RESIZE_PROPERTY.equals(property))\n\t\t{\n\t\t\tresize();\n\t\t}\n\t\telse if (COLOR_PROPERTY.equals(property))\n\t\t{\n\t\t\tresize();\n\t\t}\n\t\telse if (STATE_PROPERTY.equals(property))\n\t\t{\n\t\t\tmain.setEffect(control.hasState() ? glow : innerShadow);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void dispose()\n\t{\n\t\tcontrol.widthProperty().removeListener(sizeListener);\n\t\tcontrol.heightProperty().removeListener(sizeListener);\n\t\tcontrol.colorProperty().removeListener(colorListener);\n\t\tcontrol.stateProperty().removeListener(stateListener);\n\t\tcontrol = null;\n\t}\n\n\tprivate void resize()\n\t{\n\t\tvar width = control.getWidth() - control.getInsets().getLeft() - control.getInsets().getRight();\n\t\tvar height = control.getHeight() - control.getInsets().getTop() - control.getInsets().getBottom();\n\t\tvar size = Math.min(width, height);\n\n\t\tif (size > 0)\n\t\t{\n\t\t\tinnerShadow.setRadius(0.07 * size);\n\t\t\tglow.setRadius(0.36 * size);\n\t\t\tglow.setColor(control.getColor());\n\n\t\t\tframe.setMaxSize(size, size);\n\n\t\t\tmain.setMaxSize(0.72 * size, 0.72 * size);\n\t\t\tmain.relocate(0.14 * size, 0.14 * size);\n\t\t\tmain.setEffect(control.hasState() ? glow : innerShadow);\n\n\t\t\thighlight.setMaxSize(0.58 * size, 0.58 * size);\n\t\t\thighlight.relocate(0.21 * size, 0.21 * size);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/custom/led/LedStatus.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom.led;\n\npublic enum LedStatus\n{\n\tOK,\n\tWARNING,\n\tERROR\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/event/OpenUriEvent.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.event;\n\nimport io.xeres.common.events.SynchronousEvent;\nimport io.xeres.ui.support.uri.Uri;\n\npublic record OpenUriEvent(Uri uri) implements SynchronousEvent\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/event/StageReadyEvent.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.event;\n\nimport javafx.stage.Stage;\nimport org.springframework.context.ApplicationEvent;\n\nimport java.io.Serial;\n\npublic class StageReadyEvent extends ApplicationEvent\n{\n\t@Serial\n\tprivate static final long serialVersionUID = 346107776084028526L;\n\t\n\tprivate final transient Stage stage;\n\n\tpublic StageReadyEvent(Stage primaryStage)\n\t{\n\t\tsuper(primaryStage);\n\t\tstage = primaryStage;\n\t}\n\n\tpublic Stage getStage()\n\t{\n\t\treturn stage;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/event/UnreadEvent.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.event;\n\nimport io.xeres.common.events.SynchronousEvent;\n\npublic record UnreadEvent(Element element, boolean unread) implements SynchronousEvent\n{\n\tpublic enum Element\n\t{\n\t\tHOME,\n\t\tCONTACT,\n\t\tCHAT_ROOM,\n\t\tFORUM,\n\t\tFILE,\n\t\tCHAT,\n\t\tBOARD,\n\t\tCHANNEL\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/board/BoardGroup.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.board;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.controller.common.GxsGroup;\nimport javafx.beans.property.IntegerProperty;\nimport javafx.beans.property.SimpleIntegerProperty;\n\nimport java.time.Instant;\n\npublic class BoardGroup implements GxsGroup\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate GxsId gxsId;\n\tprivate String description;\n\tprivate boolean hasImage;\n\tprivate boolean subscribed;\n\tprivate boolean external;\n\tprivate int visibleMessageCount;\n\tprivate Instant lastActivity;\n\tprivate final IntegerProperty unreadCount = new SimpleIntegerProperty(0);\n\n\tpublic BoardGroup()\n\t{\n\t}\n\n\tpublic BoardGroup(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@Override\n\tpublic boolean isReal()\n\t{\n\t\treturn id != 0L;\n\t}\n\n\t@Override\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\t@Override\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n\n\tpublic void setDescription(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn hasImage;\n\t}\n\n\tpublic void setHasImage(boolean hasImage)\n\t{\n\t\tthis.hasImage = hasImage;\n\t}\n\n\t@Override\n\tpublic boolean isSubscribed()\n\t{\n\t\treturn subscribed;\n\t}\n\n\t@Override\n\tpublic void setSubscribed(boolean subscribed)\n\t{\n\t\tthis.subscribed = subscribed;\n\t}\n\n\t@Override\n\tpublic boolean isExternal()\n\t{\n\t\treturn external;\n\t}\n\n\tpublic void setExternal(boolean external)\n\t{\n\t\tthis.external = external;\n\t}\n\n\t@Override\n\tpublic int getVisibleMessageCount()\n\t{\n\t\treturn visibleMessageCount;\n\t}\n\n\tpublic void setVisibleMessageCount(int visibleMessageCount)\n\t{\n\t\tthis.visibleMessageCount = visibleMessageCount;\n\t}\n\n\t@Override\n\tpublic Instant getLastActivity()\n\t{\n\t\treturn lastActivity;\n\t}\n\n\tpublic void setLastActivity(Instant lastActivity)\n\t{\n\t\tthis.lastActivity = lastActivity;\n\t}\n\n\t@Override\n\tpublic boolean hasNewMessages()\n\t{\n\t\treturn unreadCount.get() > 0 && gxsId != null;\n\t}\n\n\tpublic int getUnreadCount()\n\t{\n\t\treturn unreadCount.get();\n\t}\n\n\t@Override\n\tpublic void setUnreadCount(int unreadCount)\n\t{\n\t\tthis.unreadCount.set(unreadCount);\n\t}\n\n\t@Override\n\tpublic void addUnreadCount(int value)\n\t{\n\t\tunreadCount.set(unreadCount.get() + value);\n\t}\n\n\t@Override\n\tpublic void subtractUnreadCount(int value)\n\t{\n\t\tunreadCount.set(unreadCount.get() - value);\n\t}\n\n\tpublic IntegerProperty unreadCountProperty()\n\t{\n\t\treturn unreadCount;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn getName();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/board/BoardMapper.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.board;\n\nimport io.xeres.common.dto.board.BoardGroupDTO;\nimport io.xeres.common.dto.board.BoardMessageDTO;\nimport io.xeres.ui.client.PaginatedResponse;\n\npublic final class BoardMapper\n{\n\tprivate BoardMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static BoardGroup fromDTO(BoardGroupDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar boardGroup = new BoardGroup();\n\t\tboardGroup.setId(dto.id());\n\t\tboardGroup.setName(dto.name());\n\t\tboardGroup.setGxsId(dto.gxsId());\n\t\tboardGroup.setDescription(dto.description());\n\t\tboardGroup.setHasImage(dto.hasImage());\n\t\tboardGroup.setSubscribed(dto.subscribed());\n\t\tboardGroup.setExternal(dto.external());\n\t\tboardGroup.setVisibleMessageCount(dto.visibleMessageCount());\n\t\tboardGroup.setLastActivity(dto.lastActivity());\n\t\treturn boardGroup;\n\t}\n\n\tpublic static BoardMessage fromDTO(BoardMessageDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar boardMessage = new BoardMessage();\n\t\tboardMessage.setId(dto.id());\n\t\tboardMessage.setGxsId(dto.gxsId());\n\t\tboardMessage.setMsgId(dto.msgId());\n\t\tboardMessage.setOriginalId(dto.originalId());\n\t\tboardMessage.setParentId(dto.parentId());\n\t\tboardMessage.setAuthorGxsId(dto.authorGxsId());\n\t\tboardMessage.setAuthorName(dto.authorName());\n\t\tboardMessage.setName(dto.name());\n\t\tboardMessage.setPublished(dto.published());\n\t\tboardMessage.setContent(dto.content());\n\t\tboardMessage.setLink(dto.link());\n\t\tboardMessage.setHasImage(dto.hasImage());\n\t\tboardMessage.setRead(dto.read());\n\t\tboardMessage.setImageWidth(dto.imageWidth());\n\t\tboardMessage.setImageHeight(dto.imageHeight());\n\t\treturn boardMessage;\n\t}\n\n\tpublic static PaginatedResponse<BoardMessage> fromDTO(PaginatedResponse<BoardMessageDTO> dto)\n\t{\n\t\treturn new PaginatedResponse<>(\n\t\t\t\tdto.content().stream()\n\t\t\t\t\t\t.map(BoardMapper::fromDTO)\n\t\t\t\t\t\t.toList(),\n\t\t\t\tdto.page()\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/board/BoardMessage.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.board;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.controller.common.GxsMessage;\n\nimport java.time.Instant;\n\npublic class BoardMessage implements GxsMessage\n{\n\tprivate long id;\n\tprivate GxsId gxsId;\n\tprivate MsgId msgId;\n\tprivate long originalId;\n\tprivate long parentId;\n\tprivate GxsId authorGxsId;\n\tprivate String authorName;\n\tprivate String name;\n\tprivate Instant published;\n\tprivate String content;\n\tprivate String link;\n\tprivate boolean hasImage;\n\tprivate int imageWidth;\n\tprivate int imageHeight;\n\tprivate boolean read;\n\n\tpublic BoardMessage()\n\t{\n\t\t// Needed\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\tpublic void setMsgId(MsgId msgId)\n\t{\n\t\tthis.msgId = msgId;\n\t}\n\n\t@Override\n\tpublic long getOriginalId()\n\t{\n\t\treturn originalId;\n\t}\n\n\tpublic void setOriginalId(long originalId)\n\t{\n\t\tthis.originalId = originalId;\n\t}\n\n\tpublic long getParentId()\n\t{\n\t\treturn parentId;\n\t}\n\n\tpublic void setParentId(long parentId)\n\t{\n\t\tthis.parentId = parentId;\n\t}\n\n\tpublic GxsId getAuthorGxsId()\n\t{\n\t\treturn authorGxsId;\n\t}\n\n\tpublic void setAuthorGxsId(GxsId authorGxsId)\n\t{\n\t\tthis.authorGxsId = authorGxsId;\n\t}\n\n\tpublic String getAuthorName()\n\t{\n\t\treturn authorName;\n\t}\n\n\tpublic void setAuthorName(String authorName)\n\t{\n\t\tthis.authorName = authorName;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic Instant getPublished()\n\t{\n\t\treturn published;\n\t}\n\n\tpublic void setPublished(Instant published)\n\t{\n\t\tthis.published = published;\n\t}\n\n\tpublic boolean hasContent()\n\t{\n\t\treturn StringUtils.isNotBlank(content);\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\tpublic boolean hasLink()\n\t{\n\t\treturn StringUtils.isNotBlank(link);\n\t}\n\n\tpublic String getLink()\n\t{\n\t\treturn link;\n\t}\n\n\tpublic void setLink(String link)\n\t{\n\t\tthis.link = link;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn hasImage;\n\t}\n\n\tpublic void setHasImage(boolean hasImage)\n\t{\n\t\tthis.hasImage = hasImage;\n\t}\n\n\tpublic int getImageWidth()\n\t{\n\t\treturn imageWidth;\n\t}\n\n\tpublic void setImageWidth(int imageWidth)\n\t{\n\t\tthis.imageWidth = imageWidth;\n\t}\n\n\tpublic int getImageHeight()\n\t{\n\t\treturn imageHeight;\n\t}\n\n\tpublic void setImageHeight(int imageHeight)\n\t{\n\t\tthis.imageHeight = imageHeight;\n\t}\n\n\t@Override\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\t@Override\n\tpublic void setRead(boolean read)\n\t{\n\t\tthis.read = read;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/channel/ChannelFile.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.channel;\n\nimport io.xeres.common.i18n.I18nEnum;\nimport io.xeres.common.i18n.I18nUtils;\nimport javafx.beans.property.SimpleLongProperty;\nimport javafx.beans.property.SimpleObjectProperty;\nimport javafx.beans.property.SimpleStringProperty;\n\nimport java.util.Objects;\nimport java.util.ResourceBundle;\n\npublic class ChannelFile\n{\n\tpublic enum State implements I18nEnum\n\t{\n\t\tHASHING,\n\t\tDONE;\n\n\t\tprivate final ResourceBundle bundle = I18nUtils.getBundle();\n\n\t\t@Override\n\t\tpublic String toString()\n\t\t{\n\t\t\treturn bundle.getString(getMessageKey(this));\n\t\t}\n\t}\n\n\tprivate final SimpleStringProperty name;\n\tprivate final SimpleStringProperty path;\n\tprivate final SimpleObjectProperty<State> state;\n\tprivate final SimpleLongProperty size;\n\tprivate final SimpleStringProperty hash;\n\n\tpublic ChannelFile(String name, String path, State state, long size, String hash)\n\t{\n\t\tthis.name = new SimpleStringProperty(name);\n\t\tthis.path = new SimpleStringProperty(path);\n\t\tthis.state = new SimpleObjectProperty<>(state);\n\t\tthis.size = new SimpleLongProperty(size);\n\t\tthis.hash = new SimpleStringProperty(hash);\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleStringProperty nameProperty()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name.set(name);\n\t}\n\n\tpublic String getPath()\n\t{\n\t\treturn path.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleStringProperty pathProperty()\n\t{\n\t\treturn path;\n\t}\n\n\tpublic void setPath(String path)\n\t{\n\t\tthis.path.set(path);\n\t}\n\n\tpublic State getState()\n\t{\n\t\treturn state.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleObjectProperty<State> stateProperty()\n\t{\n\t\treturn state;\n\t}\n\n\tpublic void setState(State state)\n\t{\n\t\tthis.state.set(state);\n\t}\n\n\tpublic long getSize()\n\t{\n\t\treturn size.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleLongProperty sizeProperty()\n\t{\n\t\treturn size;\n\t}\n\n\tpublic void setSize(long size)\n\t{\n\t\tthis.size.set(size);\n\t}\n\n\tpublic String getHash()\n\t{\n\t\treturn hash.get();\n\t}\n\n\t@SuppressWarnings(\"unused\")\n\tpublic SimpleStringProperty hashProperty()\n\t{\n\t\treturn hash;\n\t}\n\n\tpublic void setHash(String hash)\n\t{\n\t\tthis.hash.set(hash);\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (!(o instanceof ChannelFile that))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tif (getHash() != null)\n\t\t{\n\t\t\treturn Objects.equals(getHash(), that.getHash());\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn Objects.equals(getName(), that.getName()) && Objects.equals(getPath(), that.getPath());\n\t\t}\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\tif (getHash() != null)\n\t\t{\n\t\t\treturn Objects.hash(getHash());\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn Objects.hash(getName(), getPath());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/channel/ChannelGroup.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.channel;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.controller.common.GxsGroup;\nimport javafx.beans.property.IntegerProperty;\nimport javafx.beans.property.SimpleIntegerProperty;\n\nimport java.time.Instant;\n\npublic class ChannelGroup implements GxsGroup\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate GxsId gxsId;\n\tprivate String description;\n\tprivate boolean hasImage;\n\tprivate boolean subscribed;\n\tprivate boolean external;\n\tprivate int visibleMessageCount;\n\tprivate Instant lastActivity;\n\tprivate final IntegerProperty unreadCount = new SimpleIntegerProperty(0);\n\n\tpublic ChannelGroup()\n\t{\n\t}\n\n\tpublic ChannelGroup(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@Override\n\tpublic boolean isReal()\n\t{\n\t\treturn id != 0L;\n\t}\n\n\t@Override\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\t@Override\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n\n\tpublic void setDescription(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn hasImage;\n\t}\n\n\tpublic void setHasImage(boolean hasImage)\n\t{\n\t\tthis.hasImage = hasImage;\n\t}\n\n\t@Override\n\tpublic boolean isSubscribed()\n\t{\n\t\treturn subscribed;\n\t}\n\n\t@Override\n\tpublic void setSubscribed(boolean subscribed)\n\t{\n\t\tthis.subscribed = subscribed;\n\t}\n\n\t@Override\n\tpublic boolean isExternal()\n\t{\n\t\treturn external;\n\t}\n\n\tpublic void setExternal(boolean external)\n\t{\n\t\tthis.external = external;\n\t}\n\n\t@Override\n\tpublic int getVisibleMessageCount()\n\t{\n\t\treturn visibleMessageCount;\n\t}\n\n\tpublic void setVisibleMessageCount(int visibleMessageCount)\n\t{\n\t\tthis.visibleMessageCount = visibleMessageCount;\n\t}\n\n\t@Override\n\tpublic Instant getLastActivity()\n\t{\n\t\treturn lastActivity;\n\t}\n\n\tpublic void setLastActivity(Instant lastActivity)\n\t{\n\t\tthis.lastActivity = lastActivity;\n\t}\n\n\t@Override\n\tpublic boolean hasNewMessages()\n\t{\n\t\treturn unreadCount.get() > 0 && gxsId != null;\n\t}\n\n\tpublic int getUnreadCount()\n\t{\n\t\treturn unreadCount.get();\n\t}\n\n\t@Override\n\tpublic void setUnreadCount(int unreadCount)\n\t{\n\t\tthis.unreadCount.set(unreadCount);\n\t}\n\n\t@Override\n\tpublic void addUnreadCount(int value)\n\t{\n\t\tunreadCount.set(unreadCount.get() + value);\n\t}\n\n\t@Override\n\tpublic void subtractUnreadCount(int value)\n\t{\n\t\tunreadCount.set(unreadCount.get() - value);\n\t}\n\n\tpublic IntegerProperty unreadCountProperty()\n\t{\n\t\treturn unreadCount;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn getName();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/channel/ChannelMapper.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.channel;\n\nimport io.xeres.common.dto.channel.ChannelFileDTO;\nimport io.xeres.common.dto.channel.ChannelGroupDTO;\nimport io.xeres.common.dto.channel.ChannelMessageDTO;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.ui.client.PaginatedResponse;\n\nimport java.util.List;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class ChannelMapper\n{\n\tprivate ChannelMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChannelGroup fromDTO(ChannelGroupDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar channelGroup = new ChannelGroup();\n\t\tchannelGroup.setId(dto.id());\n\t\tchannelGroup.setName(dto.name());\n\t\tchannelGroup.setGxsId(dto.gxsId());\n\t\tchannelGroup.setDescription(dto.description());\n\t\tchannelGroup.setHasImage(dto.hasImage());\n\t\tchannelGroup.setSubscribed(dto.subscribed());\n\t\tchannelGroup.setExternal(dto.external());\n\t\tchannelGroup.setVisibleMessageCount(dto.visibleMessageCount());\n\t\tchannelGroup.setLastActivity(dto.lastActivity());\n\t\treturn channelGroup;\n\t}\n\n\tpublic static ChannelMessage fromDTO(ChannelMessageDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar channelMessage = new ChannelMessage();\n\t\tchannelMessage.setId(dto.id());\n\t\tchannelMessage.setGxsId(dto.gxsId());\n\t\tchannelMessage.setMsgId(dto.msgId());\n\t\tchannelMessage.setOriginalId(dto.originalId());\n\t\tchannelMessage.setParentId(dto.parentId());\n\t\tchannelMessage.setAuthorGxsId(dto.authorGxsId());\n\t\tchannelMessage.setAuthorName(dto.authorName());\n\t\tchannelMessage.setName(dto.name());\n\t\tchannelMessage.setPublished(dto.published());\n\t\tchannelMessage.setContent(dto.content());\n\t\tchannelMessage.setHasImage(dto.hasImage());\n\t\tchannelMessage.setImageWidth(dto.imageWidth());\n\t\tchannelMessage.setImageHeight(dto.imageHeight());\n\t\tchannelMessage.setHasFiles(dto.hasFiles());\n\t\tchannelMessage.addFiles(fromFileDTOs(dto.files()));\n\t\tchannelMessage.setRead(dto.read());\n\t\treturn channelMessage;\n\t}\n\n\tprivate static List<ChannelFile> fromFileDTOs(List<ChannelFileDTO> dtos)\n\t{\n\t\treturn emptyIfNull(dtos).stream()\n\t\t\t\t.map(ChannelMapper::fromFileDTO)\n\t\t\t\t.toList();\n\t}\n\n\tprivate static ChannelFile fromFileDTO(ChannelFileDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new ChannelFile(dto.name(), dto.path(), ChannelFile.State.DONE, dto.size(), dto.hash().toString());\n\t}\n\n\tpublic static PaginatedResponse<ChannelMessage> fromDTO(PaginatedResponse<ChannelMessageDTO> dto)\n\t{\n\t\treturn new PaginatedResponse<>(\n\t\t\t\tdto.content().stream()\n\t\t\t\t\t\t.map(ChannelMapper::fromDTO)\n\t\t\t\t\t\t.toList(),\n\t\t\t\tdto.page()\n\t\t);\n\t}\n\n\tpublic static List<ChannelFileDTO> toChannelFileDTOs(List<ChannelFile> files)\n\t{\n\t\treturn emptyIfNull(files).stream()\n\t\t\t\t.map(ChannelMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n\n\tpublic static ChannelFileDTO toDTO(ChannelFile channelFile)\n\t{\n\t\tif (channelFile == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t\treturn new ChannelFileDTO(channelFile.getSize(), Sha1Sum.fromString(channelFile.getHash()), channelFile.getName(), channelFile.getPath(), 0);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/channel/ChannelMessage.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.channel;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.controller.common.GxsMessage;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Objects;\n\npublic class ChannelMessage implements GxsMessage\n{\n\tprivate long id;\n\tprivate GxsId gxsId;\n\tprivate MsgId msgId;\n\tprivate long originalId;\n\tprivate long parentId;\n\tprivate GxsId authorGxsId;\n\tprivate String authorName;\n\tprivate String name;\n\tprivate Instant published;\n\tprivate String content;\n\tprivate boolean hasImage;\n\tprivate int imageWidth;\n\tprivate int imageHeight;\n\tprivate boolean hasFiles;\n\tprivate final List<ChannelFile> files = new ArrayList<>();\n\tprivate boolean read;\n\tprivate boolean selected; // For UI purposes only\n\n\tpublic ChannelMessage()\n\t{\n\t\t// Needed\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\tpublic void setMsgId(MsgId msgId)\n\t{\n\t\tthis.msgId = msgId;\n\t}\n\n\t@Override\n\tpublic long getOriginalId()\n\t{\n\t\treturn originalId;\n\t}\n\n\tpublic void setOriginalId(long originalId)\n\t{\n\t\tthis.originalId = originalId;\n\t}\n\n\tpublic long getParentId()\n\t{\n\t\treturn parentId;\n\t}\n\n\tpublic void setParentId(long parentId)\n\t{\n\t\tthis.parentId = parentId;\n\t}\n\n\tpublic GxsId getAuthorGxsId()\n\t{\n\t\treturn authorGxsId;\n\t}\n\n\tpublic void setAuthorGxsId(GxsId authorGxsId)\n\t{\n\t\tthis.authorGxsId = authorGxsId;\n\t}\n\n\tpublic String getAuthorName()\n\t{\n\t\treturn authorName;\n\t}\n\n\tpublic void setAuthorName(String authorName)\n\t{\n\t\tthis.authorName = authorName;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic Instant getPublished()\n\t{\n\t\treturn published;\n\t}\n\n\tpublic void setPublished(Instant published)\n\t{\n\t\tthis.published = published;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn hasImage;\n\t}\n\n\tpublic void setHasImage(boolean hasImage)\n\t{\n\t\tthis.hasImage = hasImage;\n\t}\n\n\tpublic int getImageWidth()\n\t{\n\t\treturn imageWidth;\n\t}\n\n\tpublic void setImageWidth(int imageWidth)\n\t{\n\t\tthis.imageWidth = imageWidth;\n\t}\n\n\tpublic int getImageHeight()\n\t{\n\t\treturn imageHeight;\n\t}\n\n\tpublic void setImageHeight(int imageHeight)\n\t{\n\t\tthis.imageHeight = imageHeight;\n\t}\n\n\tpublic boolean hasFiles()\n\t{\n\t\treturn hasFiles;\n\t}\n\n\tpublic void setHasFiles(boolean hasFiles)\n\t{\n\t\tthis.hasFiles = hasFiles;\n\t}\n\n\tpublic List<ChannelFile> getFiles()\n\t{\n\t\treturn Collections.unmodifiableList(files);\n\t}\n\n\tpublic void addFiles(List<ChannelFile> files)\n\t{\n\t\tthis.files.addAll(files);\n\t}\n\n\t@Override\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\t@Override\n\tpublic void setRead(boolean read)\n\t{\n\t\tthis.read = read;\n\t}\n\n\tpublic boolean isSelected()\n\t{\n\t\treturn selected;\n\t}\n\n\tpublic void setSelected(boolean selected)\n\t{\n\t\tthis.selected = selected;\n\t}\n\n\t@Override\n\tpublic boolean equals(Object o)\n\t{\n\t\tif (!(o instanceof ChannelMessage that))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\treturn id == that.id;\n\t}\n\n\t@Override\n\tpublic int hashCode()\n\t{\n\t\treturn Objects.hashCode(id);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/chat/ChatMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.chat;\n\nimport io.xeres.common.dto.chat.*;\nimport io.xeres.common.message.chat.*;\n\npublic final class ChatMapper\n{\n\tprivate ChatMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ChatRoomContext fromDTO(ChatRoomContextDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChatRoomContext(fromDTO(dto.chatRooms()), fromDTO(dto.identity()));\n\t}\n\n\tprivate static ChatRoomLists fromDTO(ChatRoomsDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar chatRoomLists = new ChatRoomLists();\n\t\tdto.available().forEach(chatRoomDTO -> chatRoomLists.addAvailable(fromDTO(chatRoomDTO)));\n\t\tdto.subscribed().forEach(chatRoomDTO -> chatRoomLists.addSubscribed(fromDTO(chatRoomDTO)));\n\t\treturn chatRoomLists;\n\t}\n\n\tprivate static ChatRoomUser fromDTO(ChatIdentityDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChatRoomUser(dto.nickname(), dto.gxsId(), dto.identityId());\n\t}\n\n\tpublic static ChatRoomInfo fromDTO(ChatRoomDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChatRoomInfo(\n\t\t\t\tdto.id(),\n\t\t\t\tdto.name(),\n\t\t\t\tdto.roomType(),\n\t\t\t\tdto.topic(),\n\t\t\t\tdto.count(),\n\t\t\t\tdto.isSigned());\n\t}\n\n\tpublic static ChatRoomBacklog fromDTO(ChatRoomBacklogDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChatRoomBacklog(\n\t\t\t\tdto.created(),\n\t\t\t\tdto.gxsId(),\n\t\t\t\tdto.nickname(),\n\t\t\t\tdto.message()\n\t\t);\n\t}\n\n\tpublic static ChatBacklog fromDTO(ChatBacklogDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChatBacklog(\n\t\t\t\tdto.created(),\n\t\t\t\tdto.own(),\n\t\t\t\tdto.message()\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/connection/Connection.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.connection;\n\nimport java.time.Instant;\n\npublic class Connection\n{\n\tprivate long id;\n\tprivate String address;\n\tprivate Instant lastConnected;\n\tprivate boolean external;\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic String getAddress()\n\t{\n\t\treturn address;\n\t}\n\n\tpublic void setAddress(String address)\n\t{\n\t\tthis.address = address;\n\t}\n\n\tpublic Instant getLastConnected()\n\t{\n\t\treturn lastConnected;\n\t}\n\n\tpublic void setLastConnected(Instant lastConnected)\n\t{\n\t\tthis.lastConnected = lastConnected;\n\t}\n\n\tpublic boolean isExternal()\n\t{\n\t\treturn external;\n\t}\n\n\tpublic void setExternal(boolean external)\n\t{\n\t\tthis.external = external;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/connection/ConnectionMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.connection;\n\nimport io.xeres.common.dto.connection.ConnectionDTO;\n\n@SuppressWarnings(\"DuplicatedCode\")\npublic final class ConnectionMapper\n{\n\tprivate ConnectionMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Connection fromDTO(ConnectionDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar connection = new Connection();\n\t\tconnection.setId(dto.id());\n\t\tconnection.setAddress(dto.address());\n\t\tconnection.setExternal(dto.external());\n\t\tconnection.setLastConnected(dto.lastConnected());\n\t\treturn connection;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/forum/ForumGroup.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.forum;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.controller.common.GxsGroup;\nimport javafx.beans.property.IntegerProperty;\nimport javafx.beans.property.SimpleIntegerProperty;\n\nimport java.time.Instant;\n\npublic class ForumGroup implements GxsGroup\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate GxsId gxsId;\n\tprivate String description;\n\tprivate boolean subscribed;\n\tprivate boolean external;\n\tprivate int visibleMessageCount;\n\tprivate Instant lastActivity;\n\tprivate final IntegerProperty unreadCount = new SimpleIntegerProperty(0);\n\n\tpublic ForumGroup()\n\t{\n\t}\n\n\tpublic ForumGroup(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@Override\n\tpublic boolean isReal()\n\t{\n\t\treturn id != 0L;\n\t}\n\n\t@Override\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\t@Override\n\tpublic String getDescription()\n\t{\n\t\treturn description;\n\t}\n\n\tpublic void setDescription(String description)\n\t{\n\t\tthis.description = description;\n\t}\n\n\t@Override\n\tpublic boolean isSubscribed()\n\t{\n\t\treturn subscribed;\n\t}\n\n\t@Override\n\tpublic void setSubscribed(boolean subscribed)\n\t{\n\t\tthis.subscribed = subscribed;\n\t}\n\n\t@Override\n\tpublic boolean isExternal()\n\t{\n\t\treturn external;\n\t}\n\n\tpublic void setExternal(boolean external)\n\t{\n\t\tthis.external = external;\n\t}\n\n\t@Override\n\tpublic int getVisibleMessageCount()\n\t{\n\t\treturn visibleMessageCount;\n\t}\n\n\tpublic void setVisibleMessageCount(int visibleMessageCount)\n\t{\n\t\tthis.visibleMessageCount = visibleMessageCount;\n\t}\n\n\t@Override\n\tpublic Instant getLastActivity()\n\t{\n\t\treturn lastActivity;\n\t}\n\n\tpublic void setLastActivity(Instant lastActivity)\n\t{\n\t\tthis.lastActivity = lastActivity;\n\t}\n\n\t@Override\n\tpublic boolean hasNewMessages()\n\t{\n\t\treturn unreadCount.get() > 0 && gxsId != null;\n\t}\n\n\tpublic int getUnreadCount()\n\t{\n\t\treturn unreadCount.get();\n\t}\n\n\t@Override\n\tpublic void setUnreadCount(int unreadCount)\n\t{\n\t\tthis.unreadCount.set(unreadCount);\n\t}\n\n\t@Override\n\tpublic void addUnreadCount(int value)\n\t{\n\t\tunreadCount.set(unreadCount.get() + value);\n\t}\n\n\t@Override\n\tpublic void subtractUnreadCount(int value)\n\t{\n\t\tunreadCount.set(unreadCount.get() - value);\n\t}\n\n\tpublic IntegerProperty unreadCountProperty()\n\t{\n\t\treturn unreadCount;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn getName();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/forum/ForumMapper.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.forum;\n\nimport io.xeres.common.dto.forum.ForumGroupDTO;\nimport io.xeres.common.dto.forum.ForumMessageDTO;\nimport io.xeres.ui.client.PaginatedResponse;\n\npublic final class ForumMapper\n{\n\tprivate ForumMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static ForumGroup fromDTO(ForumGroupDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar forumGroup = new ForumGroup();\n\t\tforumGroup.setId(dto.id());\n\t\tforumGroup.setName(dto.name());\n\t\tforumGroup.setGxsId(dto.gxsId());\n\t\tforumGroup.setDescription(dto.description());\n\t\tforumGroup.setSubscribed(dto.subscribed());\n\t\tforumGroup.setExternal(dto.external());\n\t\tforumGroup.setVisibleMessageCount(dto.visibleMessageCount());\n\t\tforumGroup.setLastActivity(dto.lastActivity());\n\t\treturn forumGroup;\n\t}\n\n\tpublic static ForumMessage fromDTO(ForumMessageDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar forumMessage = new ForumMessage();\n\t\tforumMessage.setId(dto.id());\n\t\tforumMessage.setGxsId(dto.gxsId());\n\t\tforumMessage.setMsgId(dto.msgId());\n\t\tforumMessage.setOriginalId(dto.originalId());\n\t\tforumMessage.setParentId(dto.parentId());\n\t\tforumMessage.setAuthorGxsId(dto.authorGxsId());\n\t\tforumMessage.setAuthorName(dto.authorName());\n\t\tforumMessage.setName(dto.name());\n\t\tforumMessage.setPublished(dto.published());\n\t\tforumMessage.setContent(dto.content());\n\t\tforumMessage.setRead(dto.read());\n\t\treturn forumMessage;\n\t}\n\n\tpublic static PaginatedResponse<ForumMessage> fromDTO(PaginatedResponse<ForumMessageDTO> dto)\n\t{\n\t\treturn new PaginatedResponse<>(\n\t\t\t\tdto.content().stream()\n\t\t\t\t\t\t.map(ForumMapper::fromDTO)\n\t\t\t\t\t\t.toList(),\n\t\t\t\tdto.page()\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/forum/ForumMessage.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.forum;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.controller.common.GxsMessage;\n\nimport java.time.Instant;\n\npublic class ForumMessage implements GxsMessage\n{\n\tprivate long id;\n\tprivate GxsId gxsId;\n\tprivate MsgId msgId;\n\tprivate long originalId;\n\tprivate long parentId;\n\tprivate GxsId authorGxsId;\n\tprivate String authorName;\n\tprivate String name;\n\tprivate Instant published;\n\tprivate String content;\n\tprivate boolean read;\n\n\tpublic ForumMessage()\n\t{\n\t\t// Needed\n\t}\n\n\t@Override\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\t@Override\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic MsgId getMsgId()\n\t{\n\t\treturn msgId;\n\t}\n\n\tpublic void setMsgId(MsgId msgId)\n\t{\n\t\tthis.msgId = msgId;\n\t}\n\n\t@Override\n\tpublic long getOriginalId()\n\t{\n\t\treturn originalId;\n\t}\n\n\tpublic void setOriginalId(long originalId)\n\t{\n\t\tthis.originalId = originalId;\n\t}\n\n\tpublic long getParentId()\n\t{\n\t\treturn parentId;\n\t}\n\n\tpublic void setParentId(long parentId)\n\t{\n\t\tthis.parentId = parentId;\n\t}\n\n\tpublic GxsId getAuthorGxsId()\n\t{\n\t\treturn authorGxsId;\n\t}\n\n\tpublic void setAuthorGxsId(GxsId authorGxsId)\n\t{\n\t\tthis.authorGxsId = authorGxsId;\n\t}\n\n\tpublic String getAuthorName()\n\t{\n\t\treturn authorName;\n\t}\n\n\tpublic void setAuthorName(String authorName)\n\t{\n\t\tthis.authorName = authorName;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\t@Override\n\tpublic Instant getPublished()\n\t{\n\t\treturn published;\n\t}\n\n\tpublic void setPublished(Instant published)\n\t{\n\t\tthis.published = published;\n\t}\n\n\tpublic String getContent()\n\t{\n\t\treturn content;\n\t}\n\n\tpublic void setContent(String content)\n\t{\n\t\tthis.content = content;\n\t}\n\n\t@Override\n\tpublic boolean isRead()\n\t{\n\t\treturn read;\n\t}\n\n\t@Override\n\tpublic void setRead(boolean read)\n\t{\n\t\tthis.read = read;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/identity/Identity.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.identity;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.identity.Type;\n\nimport java.time.Instant;\n\npublic class Identity\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate GxsId gxsId;\n\tprivate Instant updated;\n\tprivate Type type;\n\tprivate boolean hasImage;\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic GxsId getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\tpublic void setGxsId(GxsId gxsId)\n\t{\n\t\tthis.gxsId = gxsId;\n\t}\n\n\tpublic Instant getUpdated()\n\t{\n\t\treturn updated;\n\t}\n\n\tpublic void setUpdated(Instant updated)\n\t{\n\t\tthis.updated = updated;\n\t}\n\n\tpublic Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tpublic boolean hasImage()\n\t{\n\t\treturn hasImage;\n\t}\n\n\tpublic void setHasImage(boolean hasImage)\n\t{\n\t\tthis.hasImage = hasImage;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/identity/IdentityMapper.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.identity;\n\nimport io.xeres.common.dto.identity.IdentityDTO;\n\npublic final class IdentityMapper\n{\n\tprivate IdentityMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Identity fromDTO(IdentityDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar identity = new Identity();\n\t\tidentity.setId(dto.id());\n\t\tidentity.setName(dto.name());\n\t\tidentity.setGxsId(dto.gxsId());\n\t\tidentity.setUpdated(dto.updated());\n\t\tidentity.setType(dto.type());\n\t\tidentity.setHasImage(dto.hasImage());\n\t\treturn identity;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/location/Location.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.location;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.location.Availability;\nimport io.xeres.ui.model.connection.Connection;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class Location\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate LocationIdentifier locationIdentifier;\n\tprivate String hostname;\n\tprivate final List<Connection> connections = new ArrayList<>();\n\tprivate boolean connected;\n\tprivate Instant lastConnected;\n\tprivate Availability availability;\n\tprivate String version;\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic LocationIdentifier getLocationIdentifier()\n\t{\n\t\treturn locationIdentifier;\n\t}\n\n\tpublic void setLocationIdentifier(LocationIdentifier locationIdentifier)\n\t{\n\t\tthis.locationIdentifier = locationIdentifier;\n\t}\n\n\tpublic String getHostname()\n\t{\n\t\treturn hostname;\n\t}\n\n\tpublic void setHostname(String hostname)\n\t{\n\t\tthis.hostname = hostname;\n\t}\n\n\tpublic List<Connection> getConnections()\n\t{\n\t\treturn Collections.unmodifiableList(connections);\n\t}\n\n\tpublic void addConnections(List<Connection> connections)\n\t{\n\t\tthis.connections.addAll(connections);\n\t}\n\n\tpublic boolean isConnected()\n\t{\n\t\treturn connected;\n\t}\n\n\tpublic void setConnected(boolean connected)\n\t{\n\t\tthis.connected = connected;\n\t}\n\n\tpublic Instant getLastConnected()\n\t{\n\t\treturn lastConnected;\n\t}\n\n\tpublic void setLastConnected(Instant lastConnected)\n\t{\n\t\tthis.lastConnected = lastConnected;\n\t}\n\n\t/**\n\t * Returns the availability state. Always make sure to check {@link #isConnected()} first because\n\t * this location has no concept of offline presence.\n\t *\n\t * @return the availability\n\t */\n\tpublic Availability getAvailability()\n\t{\n\t\treturn availability;\n\t}\n\n\tpublic void setAvailability(Availability availability)\n\t{\n\t\tthis.availability = availability;\n\t}\n\n\tpublic String getVersion()\n\t{\n\t\treturn version;\n\t}\n\n\tpublic void setVersion(String version)\n\t{\n\t\tthis.version = version;\n\t}\n\n\tpublic boolean hasVersion()\n\t{\n\t\treturn StringUtils.isNotBlank(version);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/location/LocationMapper.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.location;\n\nimport io.xeres.common.dto.location.LocationDTO;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.ui.model.connection.ConnectionMapper;\n\n@SuppressWarnings(\"DuplicatedCode\")\npublic final class LocationMapper\n{\n\tprivate LocationMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Location fromDTO(LocationDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar location = new Location();\n\t\tlocation.setId(dto.id());\n\t\tlocation.setName(dto.name());\n\t\tlocation.setLocationIdentifier(new LocationIdentifier(dto.locationIdentifier()));\n\t\tlocation.setConnected(dto.connected());\n\t\tlocation.setLastConnected(dto.lastConnected());\n\t\tlocation.setAvailability(dto.availability());\n\t\tlocation.setVersion(dto.version());\n\n\t\treturn location;\n\t}\n\n\tpublic static Location fromDeepDTO(LocationDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar location = fromDTO(dto);\n\n\t\tlocation.addConnections(dto.connections().stream()\n\t\t\t\t.map(ConnectionMapper::fromDTO)\n\t\t\t\t.toList());\n\n\t\treturn location;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/profile/Profile.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.profile;\n\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.common.pgp.Trust;\nimport io.xeres.ui.model.location.Location;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static io.xeres.common.dto.profile.ProfileConstants.OWN_PROFILE_ID;\n\npublic class Profile\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate long pgpIdentifier;\n\tprivate Instant created;\n\tprivate ProfileFingerprint profileFingerprint;\n\tprivate byte[] pgpPublicKeyData;\n\tprivate boolean accepted;\n\tprivate Trust trust;\n\tprivate final List<Location> locations = new ArrayList<>();\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic long getPgpIdentifier()\n\t{\n\t\treturn pgpIdentifier;\n\t}\n\n\tpublic void setPgpIdentifier(long pgpIdentifier)\n\t{\n\t\tthis.pgpIdentifier = pgpIdentifier;\n\t}\n\n\tpublic Instant getCreated()\n\t{\n\t\treturn created;\n\t}\n\n\tpublic void setCreated(Instant created)\n\t{\n\t\tthis.created = created;\n\t}\n\n\tpublic ProfileFingerprint getProfileFingerprint()\n\t{\n\t\treturn profileFingerprint;\n\t}\n\n\tpublic void setProfileFingerprint(ProfileFingerprint profileFingerprint)\n\t{\n\t\tthis.profileFingerprint = profileFingerprint;\n\t}\n\n\tpublic byte[] getPgpPublicKeyData()\n\t{\n\t\treturn pgpPublicKeyData;\n\t}\n\n\tpublic void setPgpPublicKeyData(byte[] pgpPublicKeyData)\n\t{\n\t\tthis.pgpPublicKeyData = pgpPublicKeyData;\n\t}\n\n\tpublic boolean isAccepted()\n\t{\n\t\treturn accepted;\n\t}\n\n\tpublic void setAccepted(boolean accepted)\n\t{\n\t\tthis.accepted = accepted;\n\t}\n\n\tpublic Trust getTrust()\n\t{\n\t\treturn trust;\n\t}\n\n\tpublic void setTrust(Trust trust)\n\t{\n\t\tthis.trust = trust;\n\t}\n\n\tpublic List<Location> getLocations()\n\t{\n\t\treturn Collections.unmodifiableList(locations);\n\t}\n\n\tpublic void addLocations(List<Location> locations)\n\t{\n\t\tthis.locations.addAll(locations);\n\t}\n\n\tpublic boolean isPartial()\n\t{\n\t\treturn pgpPublicKeyData == null;\n\t}\n\n\tpublic boolean isOwn()\n\t{\n\t\treturn id == OWN_PROFILE_ID;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/profile/ProfileMapper.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.profile;\n\nimport io.xeres.common.dto.profile.ProfileDTO;\nimport io.xeres.common.id.ProfileFingerprint;\nimport io.xeres.ui.model.location.LocationMapper;\n\n@SuppressWarnings(\"DuplicatedCode\")\npublic final class ProfileMapper\n{\n\tprivate ProfileMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Profile fromDTO(ProfileDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar profile = new Profile();\n\t\tprofile.setId(dto.id());\n\t\tprofile.setName(dto.name());\n\t\tprofile.setPgpIdentifier(Long.parseLong(dto.pgpIdentifier()));\n\t\tprofile.setCreated(dto.created());\n\t\tprofile.setProfileFingerprint(new ProfileFingerprint(dto.pgpFingerprint()));\n\t\tprofile.setPgpPublicKeyData(dto.pgpPublicKeyData());\n\t\tprofile.setAccepted(dto.accepted());\n\t\tprofile.setTrust(dto.trust());\n\t\treturn profile;\n\t}\n\n\tpublic static Profile fromDeepDTO(ProfileDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar profile = fromDTO(dto);\n\n\t\tprofile.addLocations(dto.locations().stream()\n\t\t\t\t.map(LocationMapper::fromDeepDTO)\n\t\t\t\t.toList());\n\n\t\treturn profile;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/settings/Settings.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.settings;\n\nimport io.micrometer.common.util.StringUtils;\n\npublic class Settings implements Cloneable\n{\n\tprivate String torSocksHost;\n\tprivate int torSocksPort;\n\n\tprivate String i2pSocksHost;\n\tprivate int i2pSocksPort;\n\n\tprivate boolean upnpEnabled;\n\n\tprivate boolean broadcastDiscoveryEnabled;\n\n\tprivate boolean dhtEnabled;\n\n\tprivate boolean autoStartEnabled;\n\n\tprivate String incomingDirectory;\n\n\tprivate String remotePassword;\n\n\tprivate boolean remoteEnabled;\n\n\tprivate boolean isUpnpRemoteEnabled;\n\n\tprivate int remotePort;\n\n\tpublic String getTorSocksHost()\n\t{\n\t\treturn torSocksHost;\n\t}\n\n\tpublic void setTorSocksHost(String torSocksHost)\n\t{\n\t\tthis.torSocksHost = torSocksHost;\n\t}\n\n\tpublic int getTorSocksPort()\n\t{\n\t\treturn torSocksPort;\n\t}\n\n\tpublic void setTorSocksPort(int torSocksPort)\n\t{\n\t\tthis.torSocksPort = torSocksPort;\n\t}\n\n\tpublic String getI2pSocksHost()\n\t{\n\t\treturn i2pSocksHost;\n\t}\n\n\tpublic void setI2pSocksHost(String i2pSocksHost)\n\t{\n\t\tthis.i2pSocksHost = i2pSocksHost;\n\t}\n\n\tpublic int getI2pSocksPort()\n\t{\n\t\treturn i2pSocksPort;\n\t}\n\n\tpublic void setI2pSocksPort(int i2pSocksPort)\n\t{\n\t\tthis.i2pSocksPort = i2pSocksPort;\n\t}\n\n\tpublic boolean isUpnpEnabled()\n\t{\n\t\treturn upnpEnabled;\n\t}\n\n\tpublic void setUpnpEnabled(boolean upnpEnabled)\n\t{\n\t\tthis.upnpEnabled = upnpEnabled;\n\t}\n\n\tpublic boolean isBroadcastDiscoveryEnabled()\n\t{\n\t\treturn broadcastDiscoveryEnabled;\n\t}\n\n\tpublic void setBroadcastDiscoveryEnabled(boolean broadcastDiscoveryEnabled)\n\t{\n\t\tthis.broadcastDiscoveryEnabled = broadcastDiscoveryEnabled;\n\t}\n\n\tpublic boolean isDhtEnabled()\n\t{\n\t\treturn dhtEnabled;\n\t}\n\n\tpublic void setDhtEnabled(boolean dhtEnabled)\n\t{\n\t\tthis.dhtEnabled = dhtEnabled;\n\t}\n\n\tpublic boolean isAutoStartEnabled()\n\t{\n\t\treturn autoStartEnabled;\n\t}\n\n\tpublic void setAutoStartEnabled(boolean autoStartEnabled)\n\t{\n\t\tthis.autoStartEnabled = autoStartEnabled;\n\t}\n\n\tpublic boolean hasIncomingDirectory()\n\t{\n\t\treturn StringUtils.isNotEmpty(incomingDirectory);\n\t}\n\n\tpublic String getIncomingDirectory()\n\t{\n\t\treturn incomingDirectory;\n\t}\n\n\tpublic void setIncomingDirectory(String incomingDirectory)\n\t{\n\t\tthis.incomingDirectory = incomingDirectory;\n\t}\n\n\tpublic String getRemotePassword()\n\t{\n\t\treturn remotePassword;\n\t}\n\n\tpublic void setRemotePassword(String remotePassword)\n\t{\n\t\tthis.remotePassword = remotePassword;\n\t}\n\n\tpublic boolean isRemoteEnabled()\n\t{\n\t\treturn remoteEnabled;\n\t}\n\n\tpublic void setRemoteEnabled(boolean enabled)\n\t{\n\t\tremoteEnabled = enabled;\n\t}\n\n\tpublic boolean isUpnpRemoteEnabled()\n\t{\n\t\treturn isUpnpRemoteEnabled;\n\t}\n\n\tpublic void setUpnpRemoteEnabled(boolean upnpRemoteEnabled)\n\t{\n\t\tisUpnpRemoteEnabled = upnpRemoteEnabled;\n\t}\n\n\tpublic int getRemotePort()\n\t{\n\t\treturn remotePort;\n\t}\n\n\tpublic void setRemotePort(int remotePort)\n\t{\n\t\tthis.remotePort = remotePort;\n\t}\n\n\t@Override\n\tpublic Settings clone()\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn (Settings) super.clone();\n\t\t}\n\t\tcatch (CloneNotSupportedException _)\n\t\t{\n\t\t\tthrow new AssertionError();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/settings/SettingsMapper.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.settings;\n\nimport io.xeres.common.dto.settings.SettingsDTO;\n\npublic final class SettingsMapper\n{\n\tprivate SettingsMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Settings fromDTO(SettingsDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar settings = new Settings();\n\t\tsettings.setTorSocksHost(dto.torSocksHost());\n\t\tsettings.setTorSocksPort(dto.torSocksPort());\n\t\tsettings.setI2pSocksHost(dto.i2pSocksHost());\n\t\tsettings.setI2pSocksPort(dto.i2pSocksPort());\n\t\tsettings.setUpnpEnabled(dto.upnpEnabled());\n\t\tsettings.setBroadcastDiscoveryEnabled(dto.broadcastDiscoveryEnabled());\n\t\tsettings.setDhtEnabled(dto.dhtEnabled());\n\t\tsettings.setAutoStartEnabled(dto.autoStartEnabled());\n\t\tsettings.setIncomingDirectory(dto.incomingDirectory());\n\t\tsettings.setRemotePassword(dto.remotePassword());\n\t\tsettings.setRemoteEnabled(dto.remoteEnabled());\n\t\tsettings.setUpnpRemoteEnabled(dto.upnpRemoteEnabled());\n\t\tsettings.setRemotePort(dto.remotePort());\n\t\treturn settings;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/share/Share.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.share;\n\nimport io.xeres.common.pgp.Trust;\nimport javafx.beans.property.BooleanProperty;\nimport javafx.beans.property.SimpleBooleanProperty;\n\nimport java.time.Instant;\n\npublic class Share\n{\n\tprivate long id;\n\tprivate String name;\n\tprivate String path;\n\tprivate Trust browsable;\n\tprivate Instant lastScanned;\n\tprivate final BooleanProperty searchable = new SimpleBooleanProperty();\n\n\tpublic long getId()\n\t{\n\t\treturn id;\n\t}\n\n\tpublic void setId(long id)\n\t{\n\t\tthis.id = id;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic void setName(String name)\n\t{\n\t\tthis.name = name;\n\t}\n\n\tpublic String getPath()\n\t{\n\t\treturn path;\n\t}\n\n\tpublic void setPath(String path)\n\t{\n\t\tthis.path = path;\n\t}\n\n\tpublic BooleanProperty searchableProperty()\n\t{\n\t\treturn searchable;\n\t}\n\n\tpublic boolean isSearchable()\n\t{\n\t\treturn searchable.get();\n\t}\n\n\tpublic void setSearchable(boolean searchable)\n\t{\n\t\tthis.searchable.set(searchable);\n\t}\n\n\tpublic Trust getBrowsable()\n\t{\n\t\treturn browsable;\n\t}\n\n\tpublic void setBrowsable(Trust browsable)\n\t{\n\t\tthis.browsable = browsable;\n\t}\n\n\tpublic Instant getLastScanned()\n\t{\n\t\treturn lastScanned;\n\t}\n\n\tpublic void setLastScanned(Instant lastScanned)\n\t{\n\t\tthis.lastScanned = lastScanned;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/model/share/ShareMapper.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.share;\n\nimport io.xeres.common.dto.share.ShareDTO;\n\nimport java.util.List;\n\nimport static org.apache.commons.collections4.ListUtils.emptyIfNull;\n\npublic final class ShareMapper\n{\n\tprivate ShareMapper()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static Share fromDTO(ShareDTO dto)\n\t{\n\t\tif (dto == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar share = new Share();\n\t\tshare.setId(dto.id());\n\t\tshare.setName(dto.name());\n\t\tshare.setPath(dto.path());\n\t\tshare.setSearchable(dto.searchable());\n\t\tshare.setBrowsable(dto.browsable());\n\t\tshare.setLastScanned(dto.lastScanned());\n\t\treturn share;\n\t}\n\n\tpublic static ShareDTO toDTO(Share share)\n\t{\n\t\tif (share == null)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ShareDTO(share.getId(), share.getName(), share.getPath(), share.isSearchable(), share.getBrowsable(), share.getLastScanned());\n\t}\n\n\tpublic static List<ShareDTO> toDTOs(List<Share> shares)\n\t{\n\t\treturn emptyIfNull(shares).stream()\n\t\t\t\t.map(ShareMapper::toDTO)\n\t\t\t\t.toList();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/properties/UiClientProperties.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.properties;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.context.annotation.Configuration;\n\n@Configuration\n@ConfigurationProperties(prefix = \"xrs.ui.client\")\npublic class UiClientProperties\n{\n\tprivate boolean coloredEmojis = true;\n\tprivate boolean smileyToUnicode = true;\n\tprivate boolean rsEmojisAliases = true;\n\tprivate boolean OEmbed = true;\n\tprivate int imageCacheSize;\n\n\tpublic boolean isColoredEmojis()\n\t{\n\t\treturn coloredEmojis;\n\t}\n\n\tpublic void setColoredEmojis(boolean coloredEmojis)\n\t{\n\t\tthis.coloredEmojis = coloredEmojis;\n\t}\n\n\tpublic boolean isSmileyToUnicode()\n\t{\n\t\treturn smileyToUnicode;\n\t}\n\n\tpublic void setSmileyToUnicode(boolean smileyToUnicode)\n\t{\n\t\tthis.smileyToUnicode = smileyToUnicode;\n\t}\n\n\tpublic boolean isRsEmojisAliases()\n\t{\n\t\treturn rsEmojisAliases;\n\t}\n\n\tpublic void setRsEmojisAliases(boolean rsEmojisAliases)\n\t{\n\t\tthis.rsEmojisAliases = rsEmojisAliases;\n\t}\n\n\tpublic int getImageCacheSize()\n\t{\n\t\treturn imageCacheSize;\n\t}\n\n\tpublic void setImageCacheSize(int imageCacheSize)\n\t{\n\t\tthis.imageCacheSize = imageCacheSize;\n\t}\n\n\tpublic boolean isOEmbed()\n\t{\n\t\treturn OEmbed;\n\t}\n\n\tpublic void setOEmbed(boolean OEmbed)\n\t{\n\t\tthis.OEmbed = OEmbed;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/ImageCacheService.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support;\n\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.properties.UiClientProperties;\nimport javafx.scene.image.Image;\nimport org.springframework.stereotype.Service;\n\nimport java.lang.ref.ReferenceQueue;\nimport java.lang.ref.SoftReference;\nimport java.util.LinkedHashMap;\n\n/**\n * Image cache service. Can only be used on one thread (normally the JavaFX thread).\n */\n@Service\npublic class ImageCacheService implements ImageCache\n{\n\t/**\n\t * The maximum size for one image to be allowed in the cache.\n\t */\n\tprivate static final int MAX_IMAGE_SIZE = 300 * 300 * 4;\n\n\tprivate final LinkedHashMap<String, ImageSizeSoftReference> images = new LinkedHashMap<>(16, 0.75f, true);\n\tprivate final int maxSize;\n\tprivate int currentSize;\n\tprivate final ReferenceQueue<Image> referenceQueue = new ReferenceQueue<>();\n\n\tpublic ImageCacheService(UiClientProperties uiClientProperties)\n\t{\n\t\tmaxSize = uiClientProperties.getImageCacheSize() * 1024;\n\t}\n\n\t@Override\n\tpublic Image getImage(String url)\n\t{\n\t\tvar ref = images.get(url);\n\t\tif (ref != null)\n\t\t{\n\t\t\treturn ref.get();\n\t\t}\n\t\treturn null;\n\t}\n\n\t@Override\n\tpublic void putImage(String url, Image image)\n\t{\n\t\tif (!isUrlCacheable(url) || !isImageCacheable(image))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\t// Already in there\n\t\tvar ref = images.get(url);\n\t\tif (ref != null && ref.get() == image)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\t// Old entry, remove it\n\t\tif (ref != null)\n\t\t{\n\t\t\tremoveRef(ref);\n\t\t}\n\n\t\tint newSize = (int) image.getWidth() * (int) image.getHeight() * 4;\n\t\tcurrentSize += newSize;\n\n\t\tcleanupOldReferencesIfNeeded();\n\t\tcleanupOldItemsIfNeeded();\n\n\t\timages.put(url, new ImageSizeSoftReference(image, referenceQueue, url, newSize));\n\t}\n\n\t@Override\n\tpublic void evictImage(String url)\n\t{\n\t\tvar ref = images.get(url);\n\t\tif (ref != null)\n\t\t{\n\t\t\tremoveRef(ref);\n\t\t}\n\t}\n\n\t@Override\n\tpublic void evictAllImages()\n\t{\n\t\timages.clear();\n\t\tcurrentSize = 0;\n\t}\n\n\tprivate void removeRef(ImageSizeSoftReference ref)\n\t{\n\t\tcurrentSize -= ref.size;\n\t\timages.remove(ref.url);\n\t}\n\n\tprivate void cleanupOldReferencesIfNeeded()\n\t{\n\t\tImageSizeSoftReference ref;\n\t\tif (currentSize > maxSize)\n\t\t{\n\t\t\twhile ((ref = (ImageSizeSoftReference) referenceQueue.poll()) != null)\n\t\t\t{\n\t\t\t\timages.remove(ref.url);\n\t\t\t\tcurrentSize -= ref.size;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void cleanupOldItemsIfNeeded()\n\t{\n\t\tif (currentSize > maxSize)\n\t\t{\n\t\t\tvar it = images.entrySet().iterator();\n\t\t\twhile ((currentSize > maxSize) && (it.hasNext()))\n\t\t\t{\n\t\t\t\tvar entry = it.next();\n\t\t\t\tit.remove();\n\t\t\t\tcurrentSize -= entry.getValue().size;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate boolean isImageCacheable(Image image)\n\t{\n\t\treturn maxSize > 0 && image.getWidth() * image.getHeight() * 4 < MAX_IMAGE_SIZE;\n\t}\n\n\tprivate boolean isUrlCacheable(String url)\n\t{\n\t\treturn !url.startsWith(\"data:\");\n\t}\n\n\tprivate static class ImageSizeSoftReference extends SoftReference<Image>\n\t{\n\t\tprivate final String url;\n\t\tprivate final int size;\n\n\t\tpublic ImageSizeSoftReference(Image referent, ReferenceQueue<? super Image> q, String url, int size)\n\t\t{\n\t\t\tsuper(referent, q);\n\t\t\tthis.url = url;\n\t\t\tthis.size = size;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/AliasEntry.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\npublic record AliasEntry(String name, String required, String optional, String description)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/ChatAction.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport io.xeres.common.id.GxsId;\n\nimport java.util.Objects;\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.chat.ChatAction.Type.*;\n\npublic class ChatAction\n{\n\tpublic enum Type\n\t{\n\t\tJOIN,\n\t\tLEAVE,\n\t\tSAY,\n\t\tSAY_OWN,\n\t\tACTION,\n\t\tTIMEOUT\n\t}\n\n\tprivate Type type;\n\tprivate final String nickname;\n\tprivate final String gxsId;\n\n\tpublic ChatAction(Type type, String nickname, GxsId gxsId)\n\t{\n\t\tObjects.requireNonNull(type);\n\t\tObjects.requireNonNull(nickname);\n\n\t\tthis.type = type;\n\t\tthis.nickname = nickname;\n\t\tthis.gxsId = gxsId != null ? gxsId.toString() : null; // XXX: fix to always require gxsId...\n\t}\n\n\tpublic String getAction()\n\t{\n\t\treturn switch (type)\n\t\t\t\t{\n\t\t\t\t\tcase JOIN -> \"–>\";\n\t\t\t\t\tcase LEAVE, TIMEOUT -> \"<–\";\n\t\t\t\t\tcase SAY, SAY_OWN -> \"<\" + nickname + \">\";\n\t\t\t\t\tcase ACTION -> \"*\";\n\t\t\t\t};\n\t}\n\n\tpublic Type getType()\n\t{\n\t\treturn type;\n\t}\n\n\tpublic void setType(Type type)\n\t{\n\t\tthis.type = type;\n\t}\n\n\tpublic String getNickname()\n\t{\n\t\treturn nickname;\n\t}\n\n\tpublic String getGxsId()\n\t{\n\t\treturn gxsId;\n\t}\n\n\t/**\n\t * Checks if it's a presence event. Those events don't have any user content (the user cannot say anything in them).\n\t * @return true if it's a presence event (join, leave or timeout).\n\t */\n\tpublic boolean isPresenceEvent()\n\t{\n\t\treturn Stream.of(JOIN, LEAVE, TIMEOUT).anyMatch(v -> type == v);\n\t}\n\n\t/**\n\t * Gets a presence content, to put in a line.\n\t * @return the presence content\n\t */\n\tpublic String getPresenceLine()\n\t{\n\t\tif (!isPresenceEvent())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"no presence line available, type: \" + type);\n\t\t}\n\t\tvar reason = \"\";\n\t\tif (type == TIMEOUT)\n\t\t{\n\t\t\treason = \" [Ping timeout]\";\n\t\t}\n\t\treturn nickname + \" (\" + gxsId + \")\" + reason;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"ChatAction{\" +\n\t\t\t\t\"type=\" + type +\n\t\t\t\t\", nickname='\" + nickname + '\\'' +\n\t\t\t\t\", gxsId='\" + gxsId + '\\'' +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/ChatCommand.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.text.MessageFormat;\nimport java.util.List;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.regex.Pattern;\n\n/**\n * A utility class to parse outgoing commands.\n */\npublic final class ChatCommand\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ChatCommand.class);\n\n\tprivate static final Pattern SPACE_PATTERN = Pattern.compile(\"\\\\s\");\n\n\tpublic static final List<AliasEntry> ALIASES = List.of(\n\t\t\tnew AliasEntry(\"code\", \"text\", null, I18nUtils.getBundle().getString(\"chat-command.code\")),\n\t\t\tnew AliasEntry(\"flip\", null, null, I18nUtils.getBundle().getString(\"chat-command.coin\")),\n\t\t\tnew AliasEntry(\"me\", \"message\", null, I18nUtils.getBundle().getString(\"chat-command.me\")),\n\t\t\tnew AliasEntry(\"pre\", \"text\", null, I18nUtils.getBundle().getString(\"chat-command.pre\")),\n\t\t\tnew AliasEntry(\"quote\", \"text\", null, I18nUtils.getBundle().getString(\"chat-command.quote\")),\n\t\t\tnew AliasEntry(\"random\", null, \"max | min-max\", I18nUtils.getBundle().getString(\"chat-command.random\")),\n\t\t\tnew AliasEntry(\"shrug\", null, \"target\", MessageFormat.format(I18nUtils.getBundle().getString(\"chat-command-send\"), \"¯\\\\_(ツ)_/¯\")),\n\t\t\tnew AliasEntry(\"table\", null, \"target\", MessageFormat.format(I18nUtils.getBundle().getString(\"chat-command-send\"), \"(╯°□°)╯︵ ┻━┻\"))\n\t);\n\n\tprivate static final String COMMAND_CODE = \"/code \";\n\tprivate static final String COMMAND_FLIP = \"/flip\";\n\tprivate static final String COMMAND_PRE = \"/pre \";\n\tprivate static final String COMMAND_QUOTE = \"/quote \";\n\tprivate static final String COMMAND_RANDOM = \"/random\";\n\tprivate static final String COMMAND_SHRUG = \"/shrug\";\n\tprivate static final String COMMAND_TABLE = \"/table\";\n\n\tprivate ChatCommand()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * This parses outgoing commands so that they're formatted properly.\n\t *\n\t * @param s the string to be processed\n\t * @return the string with correct formatting\n\t */\n\tpublic static String parseCommands(String s)\n\t{\n\t\tif (StringUtils.isEmpty(s))\n\t\t{\n\t\t\treturn s;\n\t\t}\n\n\t\tvar pre = false;\n\n\t\tif (s.startsWith(COMMAND_CODE))\n\t\t{\n\t\t\tpre = true;\n\n\t\t\ts = s.substring(COMMAND_CODE.length());\n\t\t}\n\t\telse if (s.startsWith(COMMAND_PRE))\n\t\t{\n\t\t\tpre = true;\n\n\t\t\ts = s.substring(COMMAND_PRE.length());\n\t\t}\n\t\telse if (s.startsWith(COMMAND_QUOTE))\n\t\t{\n\t\t\ts = \"\\n> \" + s.substring(COMMAND_QUOTE.length());\n\t\t}\n\t\telse if (s.startsWith(COMMAND_FLIP))\n\t\t{\n\t\t\treturn \"🪙 (\" + (ThreadLocalRandom.current().nextBoolean() ? \"heads\" : \"tails\") + \")\";\n\t\t}\n\t\telse if (s.startsWith(COMMAND_RANDOM))\n\t\t{\n\t\t\tvar min = 1;\n\t\t\tvar max = 11;\n\n\t\t\tif (s.length() > COMMAND_RANDOM.length() + 1)\n\t\t\t{\n\t\t\t\ts = s.substring(COMMAND_RANDOM.length() + 1);\n\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\ts = SPACE_PATTERN.matcher(s).replaceAll(\"\");\n\t\t\t\t\tif (s.contains(\"-\"))\n\t\t\t\t\t{\n\t\t\t\t\t\tmin = Integer.parseInt(s.substring(0, s.indexOf(\"-\")));\n\t\t\t\t\t\tmax = Integer.parseInt(s.substring(s.indexOf(\"-\") + 1));\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tmax = Integer.parseInt(s);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcatch (NumberFormatException | IndexOutOfBoundsException exception)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Couldn't parse /random input: [{}], {}\", s, exception.getMessage());\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn \"🎲 \" + ThreadLocalRandom.current().nextInt(min, max);\n\t\t}\n\t\telse if (s.startsWith(COMMAND_SHRUG))\n\t\t{\n\t\t\treturn suffixWithSpaceIfNeeded(s.substring(COMMAND_SHRUG.length())) + \"¯\\\\\\\\\\\\_(ツ)\\\\_/¯\";\n\t\t}\n\t\telse if (s.startsWith(COMMAND_TABLE))\n\t\t{\n\t\t\treturn \"(╯°□°)╯︵ ┻━┻\" + prefixWithSpaceIfNeeded(s.substring(COMMAND_TABLE.length()));\n\t\t}\n\n\t\tif (pre)\n\t\t{\n\t\t\treturn \"\\n\" + s.indent(4);\n\t\t}\n\t\treturn s;\n\t}\n\n\tprivate static String prefixWithSpaceIfNeeded(String s)\n\t{\n\t\tif (!StringUtils.isBlank(s))\n\t\t{\n\t\t\treturn \" \" + s;\n\t\t}\n\t\treturn \"\";\n\t}\n\n\tprivate static String suffixWithSpaceIfNeeded(String s)\n\t{\n\t\tif (!StringUtils.isBlank(s))\n\t\t{\n\t\t\treturn s + \" \";\n\t\t}\n\t\treturn \"\";\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/ChatLine.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport javafx.scene.text.Text;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.time.Instant;\nimport java.util.List;\n\npublic class ChatLine\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ChatLine.class);\n\n\tprivate final Instant instant;\n\tprivate final ChatAction action;\n\tprivate final List<Content> contents;\n\n\tpublic ChatLine(Instant instant, ChatAction action, List<Content> contents)\n\t{\n\t\tthis.instant = instant;\n\t\tthis.action = action;\n\t\tif (action.isPresenceEvent())\n\t\t{\n\t\t\tif (log.isDebugEnabled() && !contents.isEmpty())\n\t\t\t{\n\t\t\t\tlog.debug(\"Chat content for action {} is not needed\", action);\n\t\t\t}\n\t\t\tthis.contents = List.of(new ContentText(action.getPresenceLine()));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tthis.contents = contents;\n\t\t}\n\t}\n\n\tpublic ChatLine withContent(List<Content> contents)\n\t{\n\t\treturn new ChatLine(instant, action, contents);\n\t}\n\n\tpublic Instant getInstant()\n\t{\n\t\treturn instant;\n\t}\n\n\tpublic String getAction()\n\t{\n\t\treturn action.getAction();\n\t}\n\n\tpublic boolean hasSaid(GxsId gxsId)\n\t{\n\t\treturn action.getType() == ChatAction.Type.SAY && gxsId.toString().equals(action.getGxsId());\n\t}\n\n\tpublic String getNicknameColor()\n\t{\n\t\treturn switch (action.getType())\n\t\t\t\t{\n\t\t\t\t\tcase SAY -> ColorGenerator.generateColor(action.getGxsId() != null ? action.getGxsId() : action.getNickname());\n\t\t\t\t\tdefault -> null;\n\t\t\t\t};\n\t}\n\n\tpublic boolean isActiveAction()\n\t{\n\t\treturn switch (action.getType())\n\t\t{\n\t\t\tcase JOIN, LEAVE, TIMEOUT -> false;\n\t\t\tcase SAY, SAY_OWN, ACTION -> true;\n\t\t};\n\t}\n\n\tpublic List<Content> getChatContents()\n\t{\n\t\treturn contents;\n\t}\n\n\t/**\n\t * Tells if a ChatLine contains \"rich\" content, that is, anything else than a line of text.\n\t *\n\t * @return true if the content is rich content\n\t */\n\tpublic boolean isRich()\n\t{\n\t\treturn contents.size() > 1 || (contents.size() == 1 && !(contents.getFirst() instanceof ContentText));\n\t}\n\n\tpublic boolean isQuote()\n\t{\n\t\treturn !contents.isEmpty() && contents.getFirst() instanceof ContentText text && ((Text) text.getNode()).getText().startsWith(\"> \");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/ChatParser.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\npublic final class ChatParser\n{\n\tprivate ChatParser()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static boolean isActionMe(String s)\n\t{\n\t\treturn s.startsWith(\"/me \");\n\t}\n\n\tpublic static String parseActionMe(String s, String nickname)\n\t{\n\t\treturn nickname + \" \" + s.substring(4);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/ColorGenerator.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Objects;\n\npublic final class ColorGenerator\n{\n\tprivate ColorGenerator()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Colors nicked from <a href=\"https://github.com/quassel\">Quassel</a>\n\t * because they are great against a white background.\n\t */\n\tprivate enum ColorSpec\n\t{\n\t\tCOLOR_00(\"color-00\"),\n\t\tCOLOR_01(\"color-01\"),\n\t\tCOLOR_02(\"color-02\"),\n\t\tCOLOR_03(\"color-03\"),\n\t\tCOLOR_04(\"color-04\"),\n\t\tCOLOR_05(\"color-05\"),\n\t\tCOLOR_06(\"color-06\"),\n\t\tCOLOR_07(\"color-07\"),\n\t\tCOLOR_08(\"color-08\"),\n\t\tCOLOR_09(\"color-09\"),\n\t\tCOLOR_10(\"color-10\"),\n\t\tCOLOR_11(\"color-11\"),\n\t\tCOLOR_12(\"color-12\"),\n\t\tCOLOR_13(\"color-13\"),\n\t\tCOLOR_14(\"color-14\"),\n\t\tCOLOR_15(\"color-15\");\n\n\t\tprivate final String color;\n\n\t\tColorSpec(String color)\n\t\t{\n\t\t\tthis.color = color;\n\t\t}\n\n\t\tpublic String getColor()\n\t\t{\n\t\t\treturn color;\n\t\t}\n\t}\n\n\tpublic static String generateColor(String s)\n\t{\n\t\tObjects.requireNonNull(s);\n\t\treturn ColorSpec.values()[Math.floorMod(s.hashCode(), ColorSpec.values().length)].getColor();\n\t}\n\n\tpublic static List<String> getAllColors()\n\t{\n\t\treturn Arrays.stream(ColorSpec.values()).map(ColorSpec::getColor).toList();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/chat/NicknameCompleter.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport java.util.function.Consumer;\n\npublic class NicknameCompleter\n{\n\tpublic interface UsernameFinder\n\t{\n\t\tString getUsername(String prefix, int index);\n\t}\n\n\tprivate UsernameFinder usernameFinder;\n\tprivate int completionIndex;\n\tprivate boolean atStart;\n\tprivate String prefix;\n\tprivate boolean hasContext;\n\tprivate String lastSuggestedNickname;\n\n\tpublic void setUsernameFinder(UsernameFinder usernameFinder)\n\t{\n\t\tthis.usernameFinder = usernameFinder;\n\t}\n\n\tpublic void complete(String line, int caretPosition, Consumer<String> action)\n\t{\n\t\tif (usernameFinder == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (!hasContext)\n\t\t{\n\t\t\tif (!line.contains(\" \"))\n\t\t\t{\n\t\t\t\tatStart = true;\n\t\t\t}\n\t\t\tprefix = findPrefix(line, caretPosition, atStart);\n\t\t\thasContext = true;\n\t\t}\n\t\tvar suggestedNickname = usernameFinder.getUsername(prefix, completionIndex);\n\t\tif (suggestedNickname != null)\n\t\t{\n\t\t\tif (atStart)\n\t\t\t{\n\t\t\t\taction.accept(suggestedNickname + \": \");\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\taction.accept((lastSuggestedNickname != null ? line.substring(0, line.length() - lastSuggestedNickname.length()) : line.substring(0, line.length() - prefix.length())) + suggestedNickname);\n\t\t\t}\n\t\t\tlastSuggestedNickname = suggestedNickname;\n\t\t}\n\t\tcompletionIndex++;\n\t}\n\n\tpublic void reset()\n\t{\n\t\tcompletionIndex = 0;\n\t\tatStart = false;\n\t\thasContext = false;\n\t\tlastSuggestedNickname = null;\n\t}\n\n\tprivate static String findPrefix(String line, int caretPosition, boolean atStart)\n\t{\n\t\tvar start = atStart ? 0 : (line.lastIndexOf(\" \", caretPosition) + 1);\n\t\treturn line.substring(start, caretPosition);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.clipboard;\n\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.scene.image.Image;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.awt.*;\nimport java.awt.datatransfer.DataFlavor;\nimport java.awt.datatransfer.StringSelection;\nimport java.awt.datatransfer.Transferable;\nimport java.awt.datatransfer.UnsupportedFlavorException;\nimport java.awt.image.BufferedImage;\nimport java.io.IOException;\n\n/**\n * Utility class to use the clipboard. This implementation uses AWT because the clipboard support of JavaFX is, quite frankly, a\n * royal piece of shit:\n * <ul>\n *     <li>it fails to work with some bitmaps (for example from Telegram, Windows 10 and print screen, Chrome, ...).\n * <li>it fails with data URIs because it tries to find out if the image is a supported format and even though it is, the URL is \"wrong\" for it.\n * </ul>\n * <p>\n * This one just works. Note that there still might be some warnings printed out because of the DataFlavor system that isn't compatible\n * with everything. It's harmless though.\n */\npublic final class ClipboardUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ClipboardUtils.class);\n\n\tprivate ClipboardUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Gets whatever is in the clipboard and supported, currently: string and JavaFX images.\n\t *\n\t * @return a string or an image. Null if there's nothing in the clipboard, or it's not supported\n\t */\n\tpublic static Object getSupportedObjectFromClipboard()\n\t{\n\t\tObject object = getImageFromClipboard();\n\t\tif (object == null)\n\t\t{\n\t\t\tobject = getStringFromClipboard();\n\t\t}\n\t\treturn object;\n\t}\n\n\t/**\n\t * Gets an image from the clipboard\n\t *\n\t * @return the image, or null if the clipboard is empty, or it doesn't contain an image\n\t */\n\tpublic static Image getImageFromClipboard()\n\t{\n\t\tvar transferable = getTransferable();\n\t\tif (transferable != null && transferable.isDataFlavorSupported(DataFlavor.imageFlavor))\n\t\t{\n\t\t\tBufferedImage image;\n\t\t\ttry\n\t\t\t{\n\t\t\t\timage = (BufferedImage) transferable.getTransferData(DataFlavor.imageFlavor);\n\t\t\t}\n\t\t\tcatch (UnsupportedFlavorException | IOException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t\treturn SwingFXUtils.toFXImage(image, null);\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Copies an image to the clipboard.\n\t *\n\t * @param image the image to copy to the clipboard\n\t */\n\tpublic static void copyImageToClipboard(Image image)\n\t{\n\t\ttry\n\t\t{\n\t\t\tToolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageSelection(SwingFXUtils.fromFXImage(image, null)), null);\n\t\t}\n\t\tcatch (HeadlessException | IllegalStateException e)\n\t\t{\n\t\t\tlog.warn(\"Clipboard not available to copy image: {}\", e.getMessage());\n\t\t}\n\t}\n\n\t/**\n\t * Gets a string from the clipboard.\n\t *\n\t * @return a string, or null if the clipboard is empty, or it doesn't contain a string\n\t */\n\tpublic static String getStringFromClipboard()\n\t{\n\t\tvar transferable = getTransferable();\n\t\tif (transferable != null && transferable.isDataFlavorSupported(DataFlavor.stringFlavor))\n\t\t{\n\t\t\tString string;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tstring = (String) transferable.getTransferData(DataFlavor.stringFlavor);\n\t\t\t}\n\t\t\tcatch (UnsupportedFlavorException | IOException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t\treturn string;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Copies a string to the clipboard.\n\t *\n\t * @param text the string to copy to the clipboard\n\t */\n\tpublic static void copyTextToClipboard(String text)\n\t{\n\t\ttry\n\t\t{\n\t\t\tToolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null);\n\t\t}\n\t\tcatch (HeadlessException | IllegalStateException e)\n\t\t{\n\t\t\tlog.warn(\"Clipboard not available to copy text: {}\", e.getMessage());\n\t\t}\n\t}\n\n\tprivate static Transferable getTransferable()\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Toolkit.getDefaultToolkit().getSystemClipboard().getContents(null);\n\t\t}\n\t\tcatch (HeadlessException | IllegalStateException e)\n\t\t{\n\t\t\tlog.warn(\"Clipboard not available to get transferable: {}\", e.getMessage());\n\t\t\treturn null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/clipboard/ImageSelection.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.clipboard;\n\nimport java.awt.datatransfer.DataFlavor;\nimport java.awt.datatransfer.Transferable;\nimport java.awt.datatransfer.UnsupportedFlavorException;\nimport java.awt.image.BufferedImage;\n\n/**\n * This class is needed to save images to the clipboard using AWT.\n */\nclass ImageSelection implements Transferable\n{\n\tprivate final BufferedImage image;\n\n\tpublic ImageSelection(BufferedImage image)\n\t{\n\t\tthis.image = image;\n\t}\n\n\t@Override\n\tpublic DataFlavor[] getTransferDataFlavors()\n\t{\n\t\treturn new DataFlavor[]{DataFlavor.imageFlavor};\n\t}\n\n\t@Override\n\tpublic boolean isDataFlavorSupported(DataFlavor flavor)\n\t{\n\t\treturn DataFlavor.imageFlavor.equals(flavor);\n\t}\n\n\t@Override\n\tpublic Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException\n\t{\n\t\tif (!DataFlavor.imageFlavor.equals(flavor))\n\t\t{\n\t\t\tthrow new UnsupportedFlavorException(flavor);\n\t\t}\n\t\treturn image;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contact/ContactUtils.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contact;\n\nimport io.xeres.common.rest.contact.Contact;\nimport io.xeres.common.util.RemoteUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport static io.xeres.common.dto.identity.IdentityConstants.NO_IDENTITY_ID;\nimport static io.xeres.common.dto.profile.ProfileConstants.NO_PROFILE_ID;\nimport static io.xeres.common.rest.PathConfig.IDENTITIES_PATH;\nimport static io.xeres.common.rest.PathConfig.PROFILES_PATH;\n\npublic final class ContactUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ContactUtils.class);\n\n\tprivate ContactUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String getIdentityImageUrl(Contact contact)\n\t{\n\t\tif (contact.identityId() != NO_IDENTITY_ID)\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + IDENTITIES_PATH + \"/\" + contact.identityId() + \"/image\";\n\t\t}\n\t\telse if (contact.profileId() != NO_PROFILE_ID)\n\t\t{\n\t\t\treturn RemoteUtils.getControlUrl() + PROFILES_PATH + \"/\" + contact.profileId() + \"/image\";\n\t\t}\n\t\tlog.error(\"Contact {} is empty\", contact);\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/Content.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\n\npublic interface Content\n{\n\tNode getNode();\n\n\tdefault String asText()\n\t{\n\t\treturn \"\";\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentCode.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.text.Text;\n\npublic class ContentCode implements Content\n{\n\tprivate static final String STYLE = \"-fx-font-family: \\\"monospace\\\"; -fx-fill: -color-success-fg\";\n\n\tprivate final Text node;\n\n\tpublic ContentCode(String text)\n\t{\n\t\tnode = new Text(text);\n\t\tnode.setStyle(STYLE);\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.text.Font;\n\npublic class ContentEmoji implements Content\n{\n\tprivate static final double SCALE_FACTOR = 1.5;\n\n\tprivate final ImageView node;\n\n\tpublic ContentEmoji(Image image, String emoji)\n\t{\n\t\tnode = new ImageView(image);\n\t\tnode.setUserData(emoji); // Used for cut & paste\n\t\tnode.setFitWidth(Font.getDefault().getSize() * SCALE_FACTOR);\n\t\tnode.setFitHeight(Font.getDefault().getSize() * SCALE_FACTOR);\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentEmphasis.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.text.Text;\n\nimport java.util.Set;\n\npublic class ContentEmphasis implements Content\n{\n\tpublic enum Style\n\t{\n\t\tBOLD,\n\t\tITALIC\n\t}\n\n\tprivate final Text node;\n\n\tpublic ContentEmphasis(String text, Set<Style> style)\n\t{\n\t\tnode = new Text(text);\n\t\tvar css = \"\";\n\t\tif (style.contains(Style.BOLD))\n\t\t{\n\t\t\tcss += \"-fx-font-weight: bold;\";\n\t\t}\n\t\tif (style.contains(Style.ITALIC))\n\t\t{\n\t\t\tcss += \"-fx-font-style: italic;\";\n\t\t}\n\t\tif (!css.isEmpty())\n\t\t{\n\t\t\tnode.setStyle(css);\n\t\t}\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentHeader.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.text.Text;\n\npublic class ContentHeader implements Content\n{\n\tprivate final Text node;\n\n\tpublic ContentHeader(String text, int size)\n\t{\n\t\tnode = new Text(text);\n\t\tnode.setStyle(\"-fx-font-size: \" + getHeaderFontSize(size) + \"px;\");\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n\n\tprivate static int getHeaderFontSize(int size)\n\t{\n\t\treturn switch (size)\n\t\t{\n\t\t\tcase 1 -> 32;\n\t\t\tcase 2 -> 24;\n\t\t\tcase 3 -> 18;\n\t\t\tcase 4 -> 16;\n\t\t\tcase 5 -> 13;\n\t\t\tcase 6 -> 10;\n\t\t\tdefault -> throw new IllegalStateException(\"Header size \" + size + \" is bigger than the maximum of 6\");\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentHorizontalRule.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.control.Separator;\n\npublic class ContentHorizontalRule implements Content\n{\n\tprivate final Separator node;\n\n\tpublic ContentHorizontalRule()\n\t{\n\t\tnode = new Separator();\n\t\tnode.setPrefWidth(200.0); // There's no easy way to know the width\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentImage.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport javafx.scene.Node;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.layout.Region;\n\npublic class ContentImage implements Content\n{\n\tprivate final ImageView node;\n\n\tpublic ContentImage(Image image)\n\t{\n\t\tthis(image, null);\n\t}\n\n\tpublic ContentImage(Image image, Region parent)\n\t{\n\t\tnode = new ImageView();\n\t\tnode.setImage(image);\n\t\tImageViewUtils.disableOutputScaling(node, parent);\n\t\tImageViewUtils.addImageContextMenuActions(node);\n\n\t\tif (parent != null)\n\t\t{\n\t\t\tsyncImageWidth(node, parent.getWidth());\n\t\t\tparent.widthProperty().addListener((_, _, newValue) -> syncImageWidth(node, newValue.doubleValue()));\n\n\t\t\tnode.setPreserveRatio(true);\n\t\t}\n\t}\n\n\tprivate static void syncImageWidth(ImageView imageView, double width)\n\t{\n\t\timageView.setFitWidth(width - 24); // margins of 12 on each side\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentStrikethrough.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.text.Text;\n\npublic class ContentStrikethrough implements Content\n{\n\tprivate static final String STYLE = \"-fx-strikethrough: true;\";\n\n\tprivate final Text node;\n\n\tpublic ContentStrikethrough(String text)\n\t{\n\t\tnode = new Text(text);\n\t\tnode.setStyle(STYLE);\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentText.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport javafx.scene.Node;\nimport javafx.scene.paint.Color;\nimport javafx.scene.text.Text;\n\npublic class ContentText implements Content\n{\n\tprivate final Text node;\n\n\tpublic ContentText(String text)\n\t{\n\t\tnode = new Text(text);\n\t}\n\n\tpublic ContentText(String text, int quoteLevel)\n\t{\n\t\tthis(text);\n\n\t\tswitch (quoteLevel)\n\t\t{\n\t\t\tcase 1 -> node.setFill(Color.GREEN);\n\t\t\tcase 2 -> node.setFill(Color.BLUE);\n\t\t\tcase 3 -> node.setFill(Color.RED);\n\t\t\tcase 4 -> node.setFill(Color.MAGENTA);\n\t\t\tcase 5 -> node.setFill(Color.YELLOW);\n\t\t\tcase 6 -> node.setFill(Color.CYAN);\n\t\t}\n\t\tif (quoteLevel > 6)\n\t\t{\n\t\t\tnode.setFill(Color.GRAY);\n\t\t}\n\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n\n\t@Override\n\tpublic String asText()\n\t{\n\t\treturn node.getText();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentUri.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.custom.DisclosedHyperlink;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.uri.Uri;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.event.ActionEvent;\nimport javafx.scene.Node;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignC;\n\nimport java.util.Objects;\nimport java.util.ResourceBundle;\nimport java.util.function.Consumer;\n\npublic class ContentUri implements Content\n{\n\tprivate final DisclosedHyperlink node;\n\tprivate final Consumer<Uri> action;\n\tprivate static final ContextMenu contextMenu;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tstatic\n\t{\n\t\tvar copyMenuItem = new MenuItem(bundle.getString(\"copy\"));\n\t\tcopyMenuItem.setGraphic(new FontIcon(MaterialDesignC.CONTENT_COPY));\n\t\tcopyMenuItem.setOnAction(ContentUri::copyToClipboard);\n\n\t\tcontextMenu = new ContextMenu(copyMenuItem);\n\t}\n\n\tpublic ContentUri(Uri uri, String description, Consumer<Uri> action)\n\t{\n\t\tthis.action = action;\n\t\tnode = new DisclosedHyperlink(description, uri.toString(), false);\n\t\tnode.setOnAction(_ -> UiUtils.askBeforeOpeningIfNeeded(node, () -> action.accept(uri)));\n\t\tinitContextMenu();\n\t}\n\n\tprivate void initContextMenu()\n\t{\n\t\tnode.setOnContextMenuRequested(event -> {\n\t\t\tcontextMenu.show(node, event.getScreenX(), event.getScreenY());\n\t\t\tevent.consume();\n\t\t});\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n\n\tpublic String getUri()\n\t{\n\t\treturn getUri(node);\n\t}\n\n\tpublic Consumer<Uri> getAction()\n\t{\n\t\treturn action;\n\t}\n\n\t@Override\n\tpublic String asText()\n\t{\n\t\treturn node.getText();\n\t}\n\n\tprivate static String getUri(Node node)\n\t{\n\t\treturn switch (node)\n\t\t{\n\t\t\tcase DisclosedHyperlink disclosedHyperlink -> disclosedHyperlink.getUri();\n\t\t\tdefault -> \"\";\n\t\t};\n\t}\n\n\tprivate static void copyToClipboard(ActionEvent event)\n\t{\n\t\tvar selectedMenuItem = (MenuItem) event.getTarget();\n\n\t\tvar popup = Objects.requireNonNull(selectedMenuItem.getParentPopup());\n\t\tClipboardUtils.copyTextToClipboard(getUri(popup.getOwnerNode()));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contentline/ContentUriPreview.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contentline;\n\nimport atlantafx.base.theme.Styles;\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.custom.DisclosedHyperlink;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.uri.Uri;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.event.ActionEvent;\nimport javafx.scene.Node;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.layout.Pane;\nimport javafx.scene.layout.VBox;\nimport org.apache.commons.lang3.StringUtils;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignC;\n\nimport java.util.Objects;\nimport java.util.ResourceBundle;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\n\n/**\n * This classes is a preview component for a URI that includes\n * a thumbnail image, title, description, site information, and a hyperlink.\n * The preview is designed to display rich content information in a user-friendly manner.\n */\npublic class ContentUriPreview implements Content\n{\n\tprivate static final int MAXIMUM_THUMBNAIL_WIDTH = 240;\n\tprivate static final int MAXIMUM_THUMBNAIL_HEIGHT = 180;\n\n\tprivate final Pane node;\n\tprivate final DisclosedHyperlink hyperlink;\n\tprivate static final ContextMenu contextMenu;\n\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tstatic\n\t{\n\t\tvar copyMenuItem = new MenuItem(bundle.getString(\"copy\"));\n\t\tcopyMenuItem.setGraphic(new FontIcon(MaterialDesignC.CONTENT_COPY));\n\t\tcopyMenuItem.setOnAction(ContentUriPreview::copyToClipboard);\n\n\t\tcontextMenu = new ContextMenu(copyMenuItem);\n\t}\n\n\t/**\n\t * Constructs a new ContentUriPreview with the specified parameters.\n\t *\n\t * @param uri             The URI to display in the preview and link to\n\t * @param title           The title text to display, can be null or empty\n\t * @param description     The description text to display, can be null or empty\n\t * @param site            The site name to display, can be null or empty\n\t * @param thumbnailUrl    The URL of the thumbnail image to load\n\t * @param thumbnailWidth  The original width of the thumbnail image, or 0 if unknown\n\t * @param thumbnailHeight The original height of the thumbnail image, or 0 if unknown\n\t * @param loader          A function to load image data from a URL\n\t * @param action          A consumer that handles the action when the hyperlink is clicked\n\t * @param renderedAction  A runnable to execute after the image has been rendered\n\t */\n\tpublic ContentUriPreview(Uri uri, String title, String description, String site, String thumbnailUrl, int thumbnailWidth, int thumbnailHeight, Function<String, byte[]> loader, Consumer<Uri> action, Runnable renderedAction)\n\t{\n\t\tvar asyncImageView = new AsyncImageView(loader);\n\t\tif (thumbnailWidth > 0 && thumbnailHeight > 0)\n\t\t{\n\t\t\tvar dimensions = ImageViewUtils.limitMaximumImageSize(thumbnailWidth, thumbnailHeight, MAXIMUM_THUMBNAIL_WIDTH, MAXIMUM_THUMBNAIL_HEIGHT);\n\t\t\tasyncImageView.setFitWidth(dimensions.getWidth());\n\t\t\tasyncImageView.setFitHeight(dimensions.getHeight());\n\t\t}\n\t\telse\n\t\t{\n\t\t\tasyncImageView.setOnSuccess(() -> {\n\t\t\t\tImageViewUtils.limitMaximumImageSize(asyncImageView, MAXIMUM_THUMBNAIL_WIDTH, MAXIMUM_THUMBNAIL_HEIGHT);\n\t\t\t\trenderedAction.run();\n\t\t\t});\n\t\t}\n\t\tasyncImageView.setUrl(thumbnailUrl);\n\n\t\tnode = new VBox(asyncImageView)\n\t\t{\n\t\t\t@Override\n\t\t\tpublic double getBaselineOffset()\n\t\t\t{\n\t\t\t\t// By default, VBox computes the baseline from its first managed children,\n\t\t\t\t// but we'd rather have the full layout.\n\t\t\t\treturn getLayoutBounds().getHeight();\n\t\t\t}\n\t\t};\n\t\tnode.getStyleClass().add(\"uri-preview\");\n\n\t\tif (StringUtils.isNotBlank(title))\n\t\t{\n\t\t\tvar titleLabel = new Label(title);\n\t\t\ttitleLabel.setWrapText(true);\n\t\t\ttitleLabel.setMaxWidth(MAXIMUM_THUMBNAIL_WIDTH);\n\t\t\ttitleLabel.getStyleClass().add(Styles.TEXT_CAPTION);\n\t\t\tnode.getChildren().add(titleLabel);\n\t\t}\n\n\t\tif (StringUtils.isNotBlank(description))\n\t\t{\n\t\t\tvar descriptionLabel = new Label(description);\n\t\t\tdescriptionLabel.setWrapText(true);\n\t\t\tdescriptionLabel.setMaxWidth(MAXIMUM_THUMBNAIL_WIDTH);\n\t\t\tdescriptionLabel.getStyleClass().add(Styles.TEXT_SMALL);\n\t\t\tnode.getChildren().add(descriptionLabel);\n\t\t}\n\n\t\tif (StringUtils.isNotBlank(site))\n\t\t{\n\t\t\tvar siteLabel = new Label(site);\n\t\t\tsiteLabel.setWrapText(true);\n\t\t\tsiteLabel.setMaxWidth(MAXIMUM_THUMBNAIL_WIDTH);\n\t\t\tsiteLabel.getStyleClass().add(Styles.TEXT_SMALL);\n\t\t\tnode.getChildren().add(siteLabel);\n\t\t}\n\n\t\thyperlink = new DisclosedHyperlink(uri.toUriString(), uri.toUriString(), false);\n\t\thyperlink.setWrappingWidth(MAXIMUM_THUMBNAIL_WIDTH);\n\t\thyperlink.setOnAction(_ -> UiUtils.askBeforeOpeningIfNeeded(hyperlink, () -> action.accept(uri)));\n\t\tUiUtils.setOnPrimaryMouseClicked(node, _ -> UiUtils.askBeforeOpeningIfNeeded(hyperlink, () -> action.accept(uri)));\n\t\tnode.getChildren().add(hyperlink);\n\t\tinitContextMenu();\n\t}\n\n\tprivate void initContextMenu()\n\t{\n\t\tnode.setOnContextMenuRequested(event -> {\n\t\t\tcontextMenu.show(node, event.getScreenX(), event.getScreenY());\n\t\t\tevent.consume();\n\t\t});\n\t}\n\n\t@Override\n\tpublic Node getNode()\n\t{\n\t\treturn node;\n\t}\n\n\t@Override\n\tpublic String asText()\n\t{\n\t\treturn hyperlink.getText();\n\t}\n\n\tprivate static void copyToClipboard(ActionEvent event)\n\t{\n\t\tvar selectedMenuItem = (MenuItem) event.getTarget();\n\n\t\tvar popup = Objects.requireNonNull(selectedMenuItem.getParentPopup());\n\t\tClipboardUtils.copyTextToClipboard(((DisclosedHyperlink) ((Pane) popup.getOwnerNode()).getChildren().stream()\n\t\t\t\t.filter(DisclosedHyperlink.class::isInstance)\n\t\t\t\t.findFirst().orElseThrow()).getUri());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/contextmenu/XContextMenu.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.contextmenu;\n\nimport javafx.event.ActionEvent;\nimport javafx.event.EventHandler;\nimport javafx.scene.Node;\nimport javafx.scene.control.*;\nimport javafx.scene.input.MouseEvent;\n\nimport java.util.function.BiPredicate;\n\n/**\n * This class simplifies context menu handling for the following classes:\n * <ul>\n *     <li>ListView</li>\n *     <li>TreeView</li>\n *     <li>TableView</li>\n *     <li>TreeTableView</li>\n *     <li>TabPane</li>\n * </ul>\n *\n * @param <T> the item of the class\n */\npublic class XContextMenu<T>\n{\n\tprivate final ContextMenu contextMenu;\n\tprivate boolean showContextMenu = true;\n\n\tpublic XContextMenu(MenuItem... menuItems)\n\t{\n\t\tEventHandler<ActionEvent> action = event -> {\n\t\t\tvar selectedMenuItem = (MenuItem) event.getTarget();\n\n\t\t\tvar popup = selectedMenuItem.getParentPopup();\n\t\t\tif (popup != null)\n\t\t\t{\n\t\t\t\tdoItemAction(event, selectedMenuItem, getItem(popup.getOwnerNode()));\n\t\t\t}\n\t\t};\n\n\t\tfor (var menuItem : menuItems)\n\t\t{\n\t\t\tif (menuItem.getUserData() != null)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"The user data of MenuItem '\" + menuItem.getText() + \"' is already set\");\n\t\t\t}\n\t\t\tmenuItem.setUserData(menuItem.getOnAction());\n\t\t\tmenuItem.setOnAction(action);\n\t\t}\n\n\t\tcontextMenu = new ContextMenu(menuItems);\n\t}\n\n\t/**\n\t * Attaches the context menu to a node. Must be called otherwise there's not\n\t * much point in creating a context menu.\n\t *\n\t * @param node the node to attach the context menu to\n\t */\n\tpublic void addToNode(Node node)\n\t{\n\t\tnode.setOnContextMenuRequested(event -> {\n\t\t\t// Using event.getSource() instead of the context menu itself (the default with setContextMenu())\n\t\t\t// allows to find out on which node the context menu was activated.\n\t\t\t// We need the following workarounds to allow closing the menu with the primary button\n\t\t\tcontextMenu.setAutoHide(true); // Workaround #1\n\t\t\tcontextMenu.show((Node) event.getSource(), event.getScreenX(), event.getScreenY());\n\t\t\tif (!showContextMenu)\n\t\t\t{\n\t\t\t\tcontextMenu.hide(); // Workaround #2: we hide immediately so that the menu is not shown (but we still fire the onShowing event as we need to know if the user wants to not show a context menu)\n\t\t\t}\n\t\t\tevent.consume();\n\t\t});\n\t\t// Workaround #3: hide the context menu on ANY mouse click, otherwise SECONDARY clicking around would reuse the same menu\n\t\tnode.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> contextMenu.hide());\n\t}\n\n\t/**\n\t * Allows to manipulate the context menu, usually disabling menu items.\n\t *\n\t * @param onShowing return true to show the menu\n\t */\n\tpublic void setOnShowing(BiPredicate<ContextMenu, T> onShowing)\n\t{\n\t\t// The even only contains the ContextMenu as source and target, which we already have,\n\t\t// so we need to find it again with getItem().\n\t\tcontextMenu.setOnShowing(event -> showContextMenu = onShowing.test(contextMenu, getItem(contextMenu.getOwnerNode())));\n\t}\n\n\tprivate void doItemAction(ActionEvent event, MenuItem selectedMenuItem, T sourceItem)\n\t{\n\t\t@SuppressWarnings(\"unchecked\") var onAction = (EventHandler<ActionEvent>) selectedMenuItem.getUserData();\n\t\tonAction.handle(event.copyFor(sourceItem, event.getTarget())); // The source is set to the item it was activated upon (for example a listview's item and not the listview itself)\n\t}\n\n\tprivate T getItem(Node ownerNode)\n\t{\n\t\tswitch (ownerNode)\n\t\t{\n\t\t\tcase TreeView<?> treeView ->\n\t\t\t{\n\t\t\t\t@SuppressWarnings(\"unchecked\") var treeItem = (TreeItem<T>) treeView.getSelectionModel().getSelectedItem();\n\t\t\t\treturn treeItem.getValue();\n\t\t\t}\n\t\t\tcase TableView<?> tableView ->\n\t\t\t{\n\t\t\t\t@SuppressWarnings(\"unchecked\") var tableItem = (T) tableView.getSelectionModel().getSelectedItem();\n\t\t\t\treturn tableItem;\n\t\t\t}\n\t\t\tcase TreeTableView<?> treeTableView ->\n\t\t\t{\n\t\t\t\t@SuppressWarnings(\"unchecked\") var treeTableItem = (T) treeTableView.getSelectionModel().getSelectedItem();\n\t\t\t\treturn treeTableItem;\n\t\t\t}\n\t\t\tcase ListView<?> listView ->\n\t\t\t{\n\t\t\t\t@SuppressWarnings(\"unchecked\") var listViewItem = (T) listView.getSelectionModel().getSelectedItem();\n\t\t\t\treturn listViewItem;\n\t\t\t}\n\t\t\tcase TabPane tabPane ->\n\t\t\t{\n\t\t\t\t//noinspection unchecked\n\t\t\t\treturn (T) tabPane.getSelectionModel().getSelectedItem();\n\t\t\t}\n\t\t\tcase null, default -> throw new IllegalArgumentException(\"Unrecognized node in context menu creation: \" + ownerNode);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/emoji/EmojiService.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.emoji;\n\nimport io.xeres.ui.properties.UiClientProperties;\nimport io.xeres.ui.support.util.SmileyUtils;\nimport javafx.scene.image.Image;\nimport org.springframework.stereotype.Service;\nimport tools.jackson.databind.json.JsonMapper;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.lang.ref.WeakReference;\nimport java.util.Arrays;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\n@Service\npublic class EmojiService\n{\n\tprivate static final String DEFAULT_UNICODE = \"2753\"; // question mark\n\tprivate static final String EMOJI_PATH = \"/image/emojis/\";\n\tprivate static final String EMOJI_EXTENSION = \".png\";\n\n\tprivate final UiClientProperties uiClientProperties;\n\tprivate RsEmojiAlias rsEmojiAlias;\n\tprivate final Map<String, WeakReference<Image>> imageCacheMap = new ConcurrentHashMap<>();\n\tprivate final Pattern aliasPattern;\n\n\tpublic EmojiService(UiClientProperties uiClientProperties, JsonMapper jsonMapper)\n\t{\n\t\tthis.uiClientProperties = uiClientProperties;\n\n\t\tif (uiClientProperties.isRsEmojisAliases())\n\t\t{\n\t\t\trsEmojiAlias = new RsEmojiAlias(jsonMapper);\n\t\t\taliasPattern = Pattern.compile(\"\\\\w{1,\" + rsEmojiAlias.getLongestAlias() + \"}\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\taliasPattern = null;\n\t\t}\n\t}\n\n\tpublic String toUnicode(String input)\n\t{\n\t\tvar s = input;\n\t\tif (uiClientProperties.isSmileyToUnicode())\n\t\t{\n\t\t\ts = SmileyUtils.smileysToUnicode(input); // ;-)\n\t\t}\n\t\tif (rsEmojiAlias != null)\n\t\t{\n\t\t\ts = parseRsEmojiAliases(s); // :wink:\n\t\t}\n\t\treturn s;\n\t}\n\n\tpublic boolean isColoredEmojis()\n\t{\n\t\treturn uiClientProperties.isColoredEmojis();\n\t}\n\n\tprivate String parseRsEmojiAliases(String s)\n\t{\n\t\tif (s.length() < 3)\n\t\t{\n\t\t\treturn s;\n\t\t}\n\n\t\tvar start = 0;\n\t\twhile ((start = s.indexOf(':', start)) != -1 && s.length() >= start + 2)\n\t\t{\n\t\t\tint end = s.indexOf(':', start + 2);\n\t\t\tif (end == -1)\n\t\t\t{\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (end - start > rsEmojiAlias.getLongestAlias() + 1)\n\t\t\t{\n\t\t\t\t// Overshot, keep searching\n\t\t\t\tstart = end;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar range = s.substring(start + 1, end);\n\n\t\t\tif (!aliasPattern.matcher(range).matches())\n\t\t\t{\n\t\t\t\t// Not an alias\n\t\t\t\tstart = end;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar alias = rsEmojiAlias.getUnicodeForAlias(range);\n\t\t\tif (alias != null)\n\t\t\t{\n\t\t\t\tvar codePoints = getCodepoints(alias);\n\t\t\t\ts = s.substring(0, start) + getCodepoints(alias) + s.substring(end + 1);\n\t\t\t\tstart += codePoints.length();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tstart = end + 1;\n\t\t\t}\n\t\t}\n\t\treturn s;\n\t}\n\n\tprivate static String getCodepoints(String unicode)\n\t{\n\t\treturn Arrays.stream(unicode.split(\"-\"))\n\t\t\t\t.map(s -> Character.toString(Integer.parseUnsignedInt(s, 16)))\n\t\t\t\t.collect(Collectors.joining());\n\t}\n\n\tpublic Image getEmoji(String emoji)\n\t{\n\t\treturn getImage(emojiToFileName(emoji));\n\t}\n\n\tString emojiToFileName(String emoji)\n\t{\n\t\tvar fileName = emoji.codePoints()\n\t\t\t\t.mapToObj(Integer::toHexString)\n\t\t\t\t.collect(Collectors.joining(\"-\"));\n\t\tif (!fileName.contains(\"-200d\")) // Twemoji doesn't use the fully qualified names\n\t\t{\n\t\t\tfileName = fileName.replace(\"-fe0f\", \"\");\n\t\t}\n\t\treturn fileName;\n\t}\n\n\tprivate Image getImage(String unicode)\n\t{\n\t\tvar reference = imageCacheMap.get(unicode);\n\t\tif (reference == null || reference.get() == null)\n\t\t{\n\t\t\ttry (var resource = getExistingUnicodeResource(unicode))\n\t\t\t{\n\t\t\t\treference = new WeakReference<>(new Image(Objects.requireNonNull(resource)));\n\t\t\t\timageCacheMap.put(unicode, reference);\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tthrow new RuntimeException(e);\n\t\t\t}\n\t\t}\n\t\treturn reference.get();\n\t}\n\n\tprivate InputStream getExistingUnicodeResource(String unicode)\n\t{\n\t\tif (EmojiService.class.getResource(EMOJI_PATH + unicode + EMOJI_EXTENSION) == null)\n\t\t{\n\t\t\tif (EmojiService.class.getResource(EMOJI_PATH + DEFAULT_UNICODE + EMOJI_EXTENSION) == null)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Missing emoji default resource\");\n\t\t\t}\n\t\t\treturn EmojiService.class.getResourceAsStream(EMOJI_PATH + DEFAULT_UNICODE + EMOJI_EXTENSION);\n\t\t}\n\t\treturn EmojiService.class.getResourceAsStream(EMOJI_PATH + unicode + EMOJI_EXTENSION);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/emoji/RsEmojiAlias.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.emoji;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport tools.jackson.core.JacksonException;\nimport tools.jackson.core.type.TypeReference;\nimport tools.jackson.databind.json.JsonMapper;\n\nimport java.util.*;\n\n/**\n * Handles shortcodes produced by Retroshare. Since they are sent directly by it in the wire,\n * typos in its database should be preserved.\n */\nclass RsEmojiAlias\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(RsEmojiAlias.class);\n\n\tprivate static final String EMOTES_DATABASE = \"/retroshare-emojis.json\";\n\n\tprivate Map<String, String> aliasesMap;\n\tprivate int longestAlias;\n\n\tprivate record AliasEntry(String alias, String unicode)\n\t{\n\t}\n\n\tRsEmojiAlias(JsonMapper jsonMapper)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar loadedAliases = jsonMapper.readValue(Objects.requireNonNull(RsEmojiAlias.class.getResourceAsStream(EMOTES_DATABASE)),\n\t\t\t\t\tnew TypeReference<List<AliasEntry>>()\n\t\t\t\t\t{\n\t\t\t\t\t});\n\n\t\t\tlog.debug(\"Loaded {} Retroshare emoji aliases\", loadedAliases.size());\n\n\t\t\taliasesMap = HashMap.newHashMap(loadedAliases.size());\n\n\t\t\tloadedAliases.forEach(aliasEntry -> aliasesMap.put(aliasEntry.alias(), aliasEntry.unicode()));\n\t\t\tlongestAlias = aliasesMap.keySet().stream()\n\t\t\t\t\t.max(Comparator.comparingInt(String::length))\n\t\t\t\t\t.orElseThrow().length();\n\t\t}\n\t\tcatch (JacksonException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't load Retroshare emoji alias database\", e);\n\t\t\taliasesMap = Map.of();\n\t\t\tlongestAlias = 0;\n\t\t}\n\t}\n\n\t/**\n\t * Gets the Unicode emoji for the alias.\n\t *\n\t * @param alias the shortcode, for example <i>wink</i>\n\t * @return the unicode emoji\n\t */\n\tString getUnicodeForAlias(String alias)\n\t{\n\t\treturn aliasesMap.get(alias);\n\t}\n\n\t/**\n\t * Gets the longest alias in the database, for optimization purposes.\n\t * @return the longest alias in the database\n\t */\n\tint getLongestAlias()\n\t{\n\t\treturn longestAlias;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/FetchMode.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\nenum FetchMode\n{\n\tALL,\n\tBEFORE,\n\tAFTER\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/FetchRequest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\nrecord FetchRequest(FetchMode fetchMode)\n{\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/InfiniteScrollable.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\ninterface InfiniteScrollable\n{\n\tvoid scrollToTop();\n\n\tvoid scrollBackwards(int numberOfEntries);\n\n\tvoid scrollForwards(int numberOfEntries);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/InfiniteTreeListView.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\nimport io.xeres.ui.controller.common.GxsMessage;\nimport javafx.geometry.Orientation;\nimport javafx.scene.control.ScrollBar;\nimport javafx.scene.control.TreeTableView;\nimport javafx.scene.input.MouseEvent;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nclass InfiniteTreeListView<M extends GxsMessage> implements InfiniteScrollable\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(InfiniteTreeListView.class);\n\n\tprivate final TreeTableView<M> treeTableView;\n\tprivate final OnDemandLoader<?, M> loader;\n\n\tprivate double scrollBarValue;\n\tprivate double scrollBarMin;\n\tprivate double scrollBarMax;\n\n\tpublic InfiniteTreeListView(TreeTableView<M> treeTableView, OnDemandLoader<?, M> loader)\n\t{\n\t\tthis.loader = loader;\n\t\tthis.treeTableView = treeTableView;\n\n\t\tthis.treeTableView.skinProperty().addListener((_, _, newValue) -> {\n\t\t\tif (newValue != null)\n\t\t\t{\n\t\t\t\ttreeTableView.applyCss();\n\t\t\t\ttreeTableView.layout();\n\n\t\t\t\tvar found = false;\n\n\t\t\t\tfor (var node : treeTableView.lookupAll(\".scroll-bar\"))\n\t\t\t\t{\n\t\t\t\t\tif (node instanceof ScrollBar scrollBar)\n\t\t\t\t\t{\n\t\t\t\t\t\tif (scrollBar.getOrientation() == Orientation.VERTICAL)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tsetupScrollBarListener(scrollBar);\n\t\t\t\t\t\t\tfound = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (!found)\n\t\t\t\t{\n\t\t\t\t\tlog.error(\"Could not find the scroll bar for the InfiniteTreeListView, it won't work properly\");\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n\n\t@Override\n\tpublic void scrollToTop()\n\t{\n\t\ttreeTableView.scrollTo(0);\n\t}\n\n\t@Override\n\tpublic void scrollBackwards(int numberOfEntries)\n\t{\n\t\ttreeTableView.scrollTo(numberOfEntries);\n\t}\n\n\t@Override\n\tpublic void scrollForwards(int numberOfEntries)\n\t{\n\t\ttreeTableView.scrollTo(loader.getTotal() - numberOfEntries);\n\t}\n\n\tprivate void setupScrollBarListener(ScrollBar scrollBar)\n\t{\n\t\tscrollBar.addEventFilter(MouseEvent.MOUSE_PRESSED, _ -> loader.setLocked(true));\n\n\t\tscrollBar.addEventFilter(MouseEvent.MOUSE_RELEASED, _ -> {\n\t\t\tloader.setLocked(false);\n\t\t\tcheckScrolling();\n\t\t});\n\n\t\tscrollBar.valueProperty().addListener((observable, oldValue, newValue) -> {\n\t\t\tscrollBarValue = newValue.doubleValue();\n\t\t\tscrollBarMin = scrollBar.getMin();\n\t\t\tscrollBarMax = scrollBar.getMax();\n\t\t\tcheckScrolling();\n\t\t});\n\t}\n\n\tprivate void checkScrolling()\n\t{\n\t\tif (loader.isLocked())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tif (scrollBarValue >= scrollBarMax)\n\t\t{\n\t\t\tloader.fetchMessages(FetchMode.AFTER);\n\t\t}\n\t\telse if (scrollBarValue <= scrollBarMin)\n\t\t{\n\t\t\tloader.fetchMessages(FetchMode.BEFORE);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/InfiniteVirtualizedScrollPane.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\nimport io.xeres.ui.controller.common.GxsMessage;\nimport javafx.scene.control.ScrollBar;\nimport javafx.scene.input.MouseEvent;\nimport org.fxmisc.flowless.VirtualFlow;\nimport org.fxmisc.flowless.VirtualizedScrollPane;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.Field;\n\nclass InfiniteVirtualizedScrollPane<M extends GxsMessage> implements InfiniteScrollable\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(InfiniteVirtualizedScrollPane.class);\n\n\tprivate final VirtualizedScrollPane<VirtualFlow<M, ?>> virtualizedScrollPane;\n\tprivate final OnDemandLoader<?, M> loader;\n\n\tpublic InfiniteVirtualizedScrollPane(VirtualizedScrollPane<?> virtualizedScrollPane, OnDemandLoader<?, M> loader)\n\t{\n\t\tthis.loader = loader;\n\t\t//noinspection unchecked\n\t\tthis.virtualizedScrollPane = (VirtualizedScrollPane<VirtualFlow<M, ?>>) virtualizedScrollPane;\n\n\t\tthis.virtualizedScrollPane.getContent().needsLayoutProperty().addListener((_, _, newValue) -> {\n\t\t\tif (newValue) // NOSONAR\n\t\t\t{\n\t\t\t\tcheckScrolling();\n\t\t\t}\n\t\t});\n\n\t\tvar vbar = getVirtualizedScrollPaneScrollBar();\n\t\tif (vbar != null)\n\t\t{\n\t\t\tvbar.addEventFilter(MouseEvent.MOUSE_PRESSED, _ -> loader.setLocked(true));\n\n\t\t\tvbar.addEventFilter(MouseEvent.MOUSE_RELEASED, _ -> {\n\t\t\t\tloader.setLocked(false);\n\t\t\t\tcheckScrolling();\n\t\t\t});\n\t\t}\n\t}\n\n\t@Override\n\tpublic void scrollToTop()\n\t{\n\t\tvirtualizedScrollPane.getContent().showAsFirst(0);\n\t}\n\n\t@Override\n\tpublic void scrollBackwards(int numberOfEntries)\n\t{\n\n\t}\n\n\t@Override\n\tpublic void scrollForwards(int numberOfEntries)\n\t{\n\n\t}\n\n\tprivate ScrollBar getVirtualizedScrollPaneScrollBar()\n\t{\n\t\tField vbarField;\n\t\ttry\n\t\t{\n\t\t\tvbarField = VirtualizedScrollPane.class.getDeclaredField(\"vbar\");\n\t\t}\n\t\tcatch (NoSuchFieldException _)\n\t\t{\n\t\t\tlog.error(\"No such field: vbar\");\n\t\t\treturn null;\n\t\t}\n\t\tvbarField.setAccessible(true); // NOSONAR\n\t\ttry\n\t\t{\n\t\t\treturn (ScrollBar) vbarField.get(virtualizedScrollPane);\n\t\t}\n\t\tcatch (IllegalAccessException _)\n\t\t{\n\t\t\tlog.error(\"No access to vbar\");\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate void checkScrolling()\n\t{\n\t\tif (loader.isLocked())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tvar firstVisibleIndex = virtualizedScrollPane.getContent().getFirstVisibleIndex();\n\t\tvar lastVisibleIndex = virtualizedScrollPane.getContent().getLastVisibleIndex();\n\n\t\tif (firstVisibleIndex == -1 || lastVisibleIndex == -1)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tif (firstVisibleIndex <= loader.getLowerBound())\n\t\t{\n\t\t\tloader.fetchMessages(FetchMode.BEFORE);\n\t\t}\n\t\telse if (lastVisibleIndex >= loader.getHigherBound())\n\t\t{\n\t\t\tloader.fetchMessages(FetchMode.AFTER);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/OnDemandLoader.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.client.GxsMessageClient;\nimport io.xeres.ui.controller.common.GxsGroup;\nimport io.xeres.ui.controller.common.GxsMessage;\nimport io.xeres.ui.support.util.UiUtils;\nimport javafx.application.Platform;\nimport javafx.collections.ObservableList;\nimport javafx.scene.control.TreeTableView;\nimport org.fxmisc.flowless.VirtualizedScrollPane;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.LinkedList;\nimport java.util.Objects;\nimport java.util.Queue;\n\n/**\n * A loader that detects when the user has scrolled enough to request loading more data. Can be used to navigate paged\n * data without having additional controls to do so.\n *\n * @param <G> the gxs group\n * @param <M> the gxs message\n */\npublic class OnDemandLoader<G extends GxsGroup, M extends GxsMessage>\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(OnDemandLoader.class);\n\n\t/**\n\t * The number of elements requested per page.\n\t */\n\tprivate static final int PAGE_SIZE = 20;\n\n\t/**\n\t * The maximum number of pages to keep loaded. When this number is exceeded,\n\t * page eviction occurs.\n\t */\n\tprivate static final int MAXIMUM_PAGES = 3;\n\n\t/**\n\t * How close to a border to start prefetching. This is untested.\n\t */\n\tprivate static final int BORDER_PREFETCH = 0;\n\n\tprivate G selectedGroup;\n\n\tprivate int basePage; // Which page is the base page, that is, offset 0 is that page\n\n\t// XXX: use those 2 to know if we can insert new data from notifications! actually that won't work... we need to know the first and last date instead :/\n\tprivate int lastPage;\n\n\tprivate boolean locked;\n\n\tprivate final Queue<FetchRequest> requests = new LinkedList<>();\n\n\tprivate final ObservableList<M> messages;\n\tprivate final GxsMessageClient<M> messageClient;\n\tprivate final OnDemandLoaderAction<G> onDemandLoaderAction;\n\n\tprivate final InfiniteScrollable infiniteScrollable;\n\n\t/**\n\t * Creates an OnDemandLoader backed by a VirtualizedScrollPane.\n\t * @param virtualizedScrollPane the virtualized scroll pane\n\t * @param messages the list of messages\n\t * @param messageClient the message client\n\t */\n\tpublic OnDemandLoader(VirtualizedScrollPane<?> virtualizedScrollPane, ObservableList<M> messages, GxsMessageClient<M> messageClient, OnDemandLoaderAction<G> action)\n\t{\n\t\tcheckConstraints();\n\n\t\tinfiniteScrollable = new InfiniteVirtualizedScrollPane<>(virtualizedScrollPane, this);\n\t\tthis.messages = messages;\n\t\tthis.messageClient = messageClient;\n\t\tonDemandLoaderAction = action;\n\t}\n\n\t/**\n\t * Creates an OnDemandLoader backed by a TreeTableView.\n\t * @param treeTableView the tree table view\n\t * @param messages the list of messages\n\t * @param messageClient the message client\n\t */\n\tpublic OnDemandLoader(TreeTableView<M> treeTableView, ObservableList<M> messages, GxsMessageClient<M> messageClient, OnDemandLoaderAction<G> action)\n\t{\n\t\tcheckConstraints();\n\n\t\tinfiniteScrollable = new InfiniteTreeListView<>(treeTableView, this);\n\t\tthis.messages = messages;\n\t\tthis.messageClient = messageClient;\n\t\tonDemandLoaderAction = action;\n\t}\n\n\t/**\n\t * Changes the selection. This will reset the messages and fetch them for the new group.\n\t * @param group the new selection group\n\t */\n\tpublic void changeSelection(G group)\n\t{\n\t\tselectedGroup = group;\n\t\tmessages.clear();\n\t\tlocked = false;\n\t\tif (selectedGroup != null)\n\t\t{\n\t\t\tif (selectedGroup.isSubscribed())\n\t\t\t{\n\t\t\t\tfetchMessages(FetchMode.ALL);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tonDemandLoaderAction.onMessagesLoaded(group);\n\t}\n\n\t/**\n\t * Inserts a new message\n\t * @param message a new incoming message\n\t * @return true if the message has been inserted, false if it has updated an already existing entry\n\t */\n\tpublic boolean insertMessage(M message)\n\t{\n\t\tif (!isSelectedGroup(message.getGxsId()))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\tvar existingMessage = messages.stream()\n\t\t\t\t.filter(existing -> existing.getId() == message.getId() || existing.getId() == message.getOriginalId())\n\t\t\t\t.findFirst();\n\t\tif (existingMessage.isPresent())\n\t\t{\n\t\t\tmessages.set(messages.indexOf(existingMessage.get()), message);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar size = messages.size();\n\t\t\tif (size == 0)\n\t\t\t{\n\t\t\t\tmessages.add(message);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tfor (var i = 0; i < size; i++)\n\t\t\t{\n\t\t\t\tif (message.getPublished().isAfter(messages.get(i).getPublished()))\n\t\t\t\t{\n\t\t\t\t\tmessages.add(i, message);\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (size < MAXIMUM_PAGES)\n\t\t\t{\n\t\t\t\tmessages.addLast(message);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\t// We are after, no need to insert\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Sets the read status of a message.\n\t * @param messageId the message id\n\t * @param read true if read\n\t */\n\tpublic void setMessageReadState(long groupId, long messageId, boolean read)\n\t{\n\t\tif (!isSelectedGroup(groupId))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tfor (var i = 0; i < messages.size(); i++)\n\t\t{\n\t\t\tvar m = messages.get(i);\n\t\t\tif (m.getId() == messageId)\n\t\t\t{\n\t\t\t\tif (m.isRead() != read)\n\t\t\t\t{\n\t\t\t\t\tm.setRead(read);\n\t\t\t\t\tmessages.set(i, m); // This produces flickering (the cell is recreated), ideally there should be a way to update cells, see: https://github.com/FXMisc/Flowless/pull/135\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Sets the read count of all messages in a group\n\t *\n\t * @param groupId the group id\n\t * @param read    true if read, false if unread\n\t */\n\tpublic void setGroupMessagesReadState(long groupId, boolean read)\n\t{\n\t\tif (!isSelectedGroup(groupId))\n\t\t{\n\t\t\tlog.error(\"Invalid group id {} when setting read state\", groupId);\n\t\t}\n\n\t\tfor (var i = 0; i < messages.size(); i++)\n\t\t{\n\t\t\tvar m = messages.get(i);\n\t\t\tif (m.isRead() != read)\n\t\t\t{\n\t\t\t\tm.setRead(read);\n\t\t\t\tmessages.set(i, m);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate boolean isSelectedGroup(long groupId)\n\t{\n\t\treturn selectedGroup != null && selectedGroup.getId() == groupId;\n\t}\n\n\tprivate boolean isSelectedGroup(GxsId groupGxsId)\n\t{\n\t\treturn selectedGroup != null && Objects.equals(selectedGroup.getGxsId(), groupGxsId);\n\t}\n\n\tvoid setLocked(boolean locked)\n\t{\n\t\tthis.locked = locked;\n\t}\n\n\tboolean isLocked()\n\t{\n\t\treturn locked;\n\t}\n\n\tint getLowerBound()\n\t{\n\t\treturn BORDER_PREFETCH;\n\t}\n\n\tint getHigherBound()\n\t{\n\t\treturn messages.size() - 1 - BORDER_PREFETCH;\n\t}\n\n\tint getTotal()\n\t{\n\t\treturn messages.size();\n\t}\n\n\tvoid fetchMessages(FetchMode fetchMode)\n\t{\n\t\tif (selectedGroup == null || !selectedGroup.isSubscribed())\n\t\t{\n\t\t\tlog.warn(\"Attempt to load a group that is not able to\");\n\t\t\treturn;\n\t\t}\n\n\t\tif (requests.stream().anyMatch(fetchRequest -> fetchRequest.fetchMode() == fetchMode))\n\t\t{\n\t\t\tlog.warn(\"There's already a pending request with the same fetch mode as {}, ignoring\", fetchMode);\n\t\t\treturn;\n\t\t}\n\n\t\tvar page = 0;\n\n\t\tswitch (fetchMode)\n\t\t{\n\t\t\tcase ALL -> basePage = 0;\n\t\t\tcase BEFORE ->\n\t\t\t{\n\t\t\t\tif (basePage == 0)\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Already on first page, not fetching anything\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tpage = basePage - 1;\n\t\t\t}\n\t\t\tcase AFTER ->\n\t\t\t{\n\t\t\t\tif (messages.size() < PAGE_SIZE || basePage + messages.size() / PAGE_SIZE > lastPage) // XXX: double check... added messages.size() smaller condition...\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Already on the last page, not fetching anything\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tpage = basePage + messages.size() / PAGE_SIZE;\n\t\t\t}\n\t\t}\n\n\t\trequests.add(new FetchRequest(fetchMode));\n\n\t\tlog.debug(\"Fetching page {}\", page);\n\t\tlocked = true;\n\n\t\tmessageClient.getMessages(selectedGroup.getId(), page, PAGE_SIZE)\n\t\t\t\t// XXX: progress bar too? only for the first fetch I guess...\n\t\t\t\t.doOnSuccess(paginatedResponse -> Platform.runLater(() -> {\n\t\t\t\t\tassert paginatedResponse != null;\n\n\t\t\t\t\tlastPage = paginatedResponse.page().totalPages() - 1; // This keeps the lastPage up to date\n\n\t\t\t\t\tswitch (fetchMode)\n\t\t\t\t\t{\n\t\t\t\t\t\tcase ALL ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.debug(\"Fetched all ({})\", paginatedResponse.numberOfElements());\n\t\t\t\t\t\t\tif (!paginatedResponse.empty())\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmessages.addAll(paginatedResponse.content());\n\t\t\t\t\t\t\t\tinfiniteScrollable.scrollToTop();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase BEFORE ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.debug(\"Fetching before ({})\", paginatedResponse.numberOfElements());\n\t\t\t\t\t\t\tmessages.addAll(0, paginatedResponse.content());\n\t\t\t\t\t\t\tcleanup(fetchMode);\n\t\t\t\t\t\t\tinfiniteScrollable.scrollBackwards(paginatedResponse.numberOfElements());\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase AFTER ->\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog.debug(\"Fetching after ({})\", paginatedResponse.numberOfElements());\n\t\t\t\t\t\t\tmessages.addAll(paginatedResponse.content());\n\t\t\t\t\t\t\tcleanup(fetchMode);\n\t\t\t\t\t\t\tinfiniteScrollable.scrollForwards(paginatedResponse.numberOfElements());\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tlocked = false;\n\n\t\t\t\t\t// Request has been processed so remove it\n\t\t\t\t\trequests.removeIf(fetchRequest -> fetchRequest.fetchMode() == fetchMode);\n\t\t\t\t\tonDemandLoaderAction.onMessagesLoaded(selectedGroup);\n\t\t\t\t}))\n\t\t\t\t.doOnError(UiUtils::webAlertError) // XXX: cleanup on error?\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void checkConstraints()\n\t{\n\t\t//noinspection ConstantValue\n\t\tif (BORDER_PREFETCH >= PAGE_SIZE)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"BORDER_PREFETCH must not be bigger than PAGE_SIZE\");\n\t\t}\n\t}\n\n\tprivate int cleanup(FetchMode fetchMode)\n\t{\n\t\tvar totalRemoved = 0;\n\t\tif (requests.stream().noneMatch(fetchRequest -> fetchRequest.fetchMode() == fetchMode))\n\t\t{\n\t\t\tlog.warn(\"Missing request {} for cleanup action. Shouldn't happen\", fetchMode);\n\t\t\treturn totalRemoved;\n\t\t}\n\n\t\tint messagesToRemove;\n\t\twhile ((messagesToRemove = messages.size() - PAGE_SIZE * MAXIMUM_PAGES) > 0)\n\t\t{\n\t\t\tlog.debug(\"Trimming message size from {} to {}\", messages.size(), PAGE_SIZE * MAXIMUM_PAGES);\n\n\t\t\tvar sliceToRemove = Math.min(PAGE_SIZE, messagesToRemove);\n\n\t\t\t// Find which side to chop off\n\t\t\tswitch (fetchMode)\n\t\t\t{\n\t\t\t\tcase AFTER ->\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Trimming {} from beginning\", sliceToRemove);\n\t\t\t\t\tmessages.remove(0, sliceToRemove);\n\t\t\t\t\tbasePage++; // XXX: too simplistic... what happens if sliceToRemove is less than PAGE_SIZE?\n\n\t\t\t\t\ttotalRemoved -= sliceToRemove;\n\t\t\t\t}\n\t\t\t\tcase BEFORE ->\n\t\t\t\t{\n\t\t\t\t\tlog.debug(\"Trimming {} from end\", sliceToRemove);\n\t\t\t\t\tmessages.remove(messages.size() - sliceToRemove, messages.size());\n\t\t\t\t\tbasePage--; // XXX: ditto...\n\n\t\t\t\t\ttotalRemoved -= sliceToRemove;\n\t\t\t\t}\n\t\t\t\tcase null, default -> log.error(\"Can't happen\");\n\t\t\t}\n\t\t}\n\t\treturn totalRemoved;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/loader/OnDemandLoaderAction.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.loader;\n\nimport io.xeres.ui.controller.common.GxsGroup;\n\npublic interface OnDemandLoaderAction<G extends GxsGroup>\n{\n\tvoid onMessagesLoaded(G group);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/markdown/AltTextVisitor.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.markdown;\n\nimport org.commonmark.node.AbstractVisitor;\nimport org.commonmark.node.HardLineBreak;\nimport org.commonmark.node.SoftLineBreak;\nimport org.commonmark.node.Text;\n\nclass AltTextVisitor extends AbstractVisitor\n{\n\tprivate final StringBuilder sb = new StringBuilder();\n\n\tString getAltText()\n\t{\n\t\treturn sb.toString();\n\t}\n\n\t@Override\n\tpublic void visit(Text text)\n\t{\n\t\tsb.append(text.getLiteral());\n\t}\n\n\t@Override\n\tpublic void visit(SoftLineBreak softLineBreak)\n\t{\n\t\tsb.append('\\n');\n\t}\n\n\t@Override\n\tpublic void visit(HardLineBreak hardLineBreak)\n\t{\n\t\tsb.append('\\n');\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/markdown/ContentRenderer.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.markdown;\n\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.emoji.EmojiService;\nimport io.xeres.ui.support.markdown.MarkdownService.Rendering;\nimport org.commonmark.node.Node;\n\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Set;\n\nclass ContentRenderer\n{\n\tprivate final EmojiService emojiService;\n\tprivate final UriAction uriAction;\n\tprivate final boolean chatMode;\n\tprivate final boolean textReflow;\n\n\tContentRenderer(EmojiService emojiService, Set<Rendering> rendering, UriAction uriAction)\n\t{\n\t\tthis.emojiService = emojiService;\n\t\tchatMode = rendering.contains(Rendering.CHAT);\n\t\ttextReflow = rendering.contains(Rendering.TEXT_REFLOW);\n\t\tthis.uriAction = uriAction;\n\t}\n\n\tpublic List<Content> render(Node node)\n\t{\n\t\tObjects.requireNonNull(node, \"Node must not be null\");\n\n\t\tvar visitor = new ContentVisitor(emojiService, chatMode, textReflow, uriAction);\n\t\tnode.accept(visitor);\n\t\treturn visitor.getContent();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/markdown/ContentVisitor.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.markdown;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.ui.support.contentline.*;\nimport io.xeres.ui.support.emoji.EmojiService;\nimport io.xeres.ui.support.uri.UriFactory;\nimport io.xeres.ui.support.util.ImageViewUtils;\nimport io.xeres.ui.support.util.Range;\nimport org.commonmark.ext.gfm.strikethrough.Strikethrough;\nimport org.commonmark.node.*;\nimport org.jsoup.Jsoup;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.regex.Pattern;\n\nclass ContentVisitor extends AbstractVisitor\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ContentVisitor.class);\n\n\tprivate static final Pattern EMOJI_PATTERN = Pattern.compile(\"(\" +\n\t\t\t\"[\\\\x{1F1E6}-\\\\x{1F1FF}]{2}\" + // Regional Indicator\n\t\t\t\"|(\\\\p{IsEmoji}\" + // Emoji Sequence\n\t\t\t\"(\\\\p{IsEmoji_Modifier}\" +\n\t\t\t\"|\\\\x{FE0F}\\\\x{20E3}?\" + // Emoji Presentation Sequence (with optional Keycap)\n\t\t\t\"|[\\\\x{E0020}-\\\\x{E007E}]+\\\\x{E007F}\" + // Emoji Tag Sequence\n\t\t\t\")\" +\n\t\t\t\"|\\\\p{IsEmoji_Presentation}\" + // Single Character Emoji\n\t\t\t\")\" +\n\t\t\t\"(\\\\x{200D}\" + // Emoji Zero-Width Joiner Sequence (ZWJ)\n\t\t\t\"(\\\\p{IsEmoji}\" +\n\t\t\t\"(\\\\p{IsEmoji_Modifier}\" +\n\t\t\t\"|\\\\x{FE0F}\\\\x{20E3}?\" +\n\t\t\t\"|[\\\\x{E0020}-\\\\x{E007E}]+\\\\x{E007F}\" +\n\t\t\t\")\" +\n\t\t\t\"|\\\\p{IsEmoji_Presentation}\" +\n\t\t\t\")\" +\n\t\t\t\"){0,256}\" +\n\t\t\t\")\");\n\n\tprivate enum ParsingMode\n\t{\n\t\tNORMAL,\n\t\tQUOTE,\n\t\tORDERED\n\t}\n\n\tprivate final EmojiService emojiService;\n\tprivate final UriAction uriAction;\n\n\tprivate final List<Content> content = new ArrayList<>();\n\tprivate ParsingMode parsingMode = ParsingMode.NORMAL;\n\tprivate int quoteLevel;\n\tprivate final boolean chatMode;\n\tprivate final boolean textReflow;\n\tprivate int listCounter = 1;\n\n\tContentVisitor(EmojiService emojiService, boolean chatMode, boolean textReflow, UriAction uriAction)\n\t{\n\t\tthis.emojiService = emojiService;\n\t\tthis.chatMode = chatMode;\n\t\tthis.textReflow = textReflow;\n\t\tthis.uriAction = uriAction;\n\t}\n\n\tList<Content> getContent()\n\t{\n\t\tif (!content.isEmpty() && content.getLast() instanceof ContentText contentText)\n\t\t{\n\t\t\t// Remove useless last line\n\t\t\tif (contentText.asText().equals(\"\\n\"))\n\t\t\t{\n\t\t\t\tcontent.removeLast();\n\t\t\t}\n\t\t}\n\t\treturn content;\n\t}\n\n\t@Override\n\tpublic void visit(Text text)\n\t{\n\t\tif (parsingMode == ParsingMode.QUOTE)\n\t\t{\n\t\t\tcontent.add(new ContentText(\">\".repeat(quoteLevel) + \" \", quoteLevel));\n\t\t}\n\n\t\tvar s = text.getLiteral();\n\n\t\ts = emojiService.toUnicode(s);\n\n\t\tif (emojiService.isColoredEmojis() && mightContainEmojis(s))\n\t\t{\n\t\t\thandleEmojis(s, content);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (parsingMode == ParsingMode.QUOTE)\n\t\t\t{\n\t\t\t\tcontent.add(new ContentText(s, quoteLevel));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontent.add(new ContentText(s));\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void handleEmojis(String line, List<Content> content)\n\t{\n\t\tvar matcher = EMOJI_PATTERN.matcher(line);\n\t\tvar previousRange = new Range(0, 0);\n\n\t\twhile (matcher.find())\n\t\t{\n\t\t\tvar currentRange = new Range(matcher);\n\n\t\t\t// Before/between matches\n\t\t\tvar betweenRange = currentRange.outerRange(previousRange);\n\t\t\tif (betweenRange.hasRange())\n\t\t\t{\n\t\t\t\tcontent.add(new ContentText(line.substring(betweenRange.start(), betweenRange.end())));\n\t\t\t}\n\n\t\t\t// Match\n\t\t\tvar range = line.substring(currentRange.start(), currentRange.end());\n\t\t\tcontent.add(new ContentEmoji(emojiService.getEmoji(range), range));\n\n\t\t\tpreviousRange = currentRange;\n\t\t}\n\n\t\tif (!previousRange.hasRange())\n\t\t{\n\t\t\t// If no match at all\n\t\t\tcontent.add(new ContentText(line));\n\t\t}\n\t\telse if (previousRange.end() < line.length())\n\t\t{\n\t\t\t// After the last match\n\t\t\tcontent.add(new ContentText(line.substring(previousRange.end())));\n\t\t}\n\t}\n\n\t@Override\n\tpublic void visit(Heading heading)\n\t{\n\t\tif (chatMode)\n\t\t{\n\t\t\tcontent.add(new ContentText(\"#\".repeat(Math.max(0, heading.getLevel())) + \" \" + getFirstTextChild(heading).orElse(\"\")));\n\t\t}\n\t\telse\n\t\t{\n\t\t\taddEmptyLine();\n\t\t\tcontent.add(new ContentHeader(getFirstTextChild(heading).orElse(\"\"), heading.getLevel()));\n\t\t\taddEmptyLine();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void visit(SoftLineBreak softLineBreak)\n\t{\n\t\tif (textReflow && parsingMode != ParsingMode.QUOTE)\n\t\t{\n\t\t\tcontent.add(new ContentText(\" \"));\n\t\t}\n\t\telse\n\t\t{\n\t\t\taddEmptyLine();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void visit(HardLineBreak hardLineBreak)\n\t{\n\t\taddEmptyLine();\n\t}\n\n\t@Override\n\tpublic void visit(Paragraph paragraph)\n\t{\n\t\tif (!isInTightList(paragraph))\n\t\t{\n\t\t\taddEmptyLine();\n\t\t}\n\t\tvisitChildren(paragraph);\n\t\tif (!isInTightList(paragraph))\n\t\t{\n\t\t\taddEmptyLine();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void visit(Link link)\n\t{\n\t\tvar url = link.getDestination();\n\n\t\tvar altTextVisitor = new AltTextVisitor();\n\t\tlink.accept(altTextVisitor);\n\t\tvar altText = altTextVisitor.getAltText();\n\n\t\t// Only use the altText if it's different from the URL. Otherwise, this can cause problems (URL decoded but no the text, etc...)\n\t\tcontent.add(UriFactory.createContent(url, url.equals(altText) ? null : altText, uriAction));\n\t}\n\n\t@Override\n\tpublic void visit(BlockQuote blockQuote)\n\t{\n\t\tparsingMode = ParsingMode.QUOTE;\n\t\tquoteLevel++;\n\t\tvisitChildren(blockQuote);\n\t\tparsingMode = ParsingMode.NORMAL;\n\t\tquoteLevel--;\n\t}\n\n\t@Override\n\tpublic void visit(BulletList bulletList)\n\t{\n\t\taddEmptyLine();\n\t\tvisitChildren(bulletList);\n\t}\n\n\t@Override\n\tpublic void visit(OrderedList orderedList)\n\t{\n\t\tparsingMode = ParsingMode.ORDERED;\n\t\taddEmptyLine();\n\t\tvisitChildren(orderedList);\n\t\tparsingMode = ParsingMode.NORMAL;\n\t\tlistCounter = 1;\n\t}\n\n\t@Override\n\tpublic void visit(ListItem listItem)\n\t{\n\t\tif (parsingMode == ParsingMode.ORDERED)\n\t\t{\n\t\t\tif (chatMode)\n\t\t\t{\n\t\t\t\tvar start = ((OrderedList) listItem.getParent()).getMarkerStartNumber();\n\t\t\t\tcontent.add(new ContentText(start + \". \"));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontent.add(new ContentText(String.format(\"%3d. \", listCounter++)));\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tcontent.add(new ContentText(\"• \"));\n\t\t}\n\t\tvisitChildren(listItem);\n\t\taddEmptyLine();\n\t}\n\n\t@Override\n\tpublic void visit(Emphasis emphasis)\n\t{\n\t\tcontent.add(new ContentEmphasis(getFirstTextChild(emphasis).orElse(\"\"), EnumSet.of(ContentEmphasis.Style.ITALIC)));\n\t}\n\n\t@Override\n\tpublic void visit(StrongEmphasis strongEmphasis)\n\t{\n\t\tcontent.add(new ContentEmphasis(getFirstTextChild(strongEmphasis).orElse(\"\"), EnumSet.of(ContentEmphasis.Style.BOLD)));\n\t}\n\n\t@Override\n\tpublic void visit(Code code)\n\t{\n\t\tcontent.add(new ContentCode(stripLastLn(code.getLiteral())));\n\t}\n\n\t@Override\n\tpublic void visit(IndentedCodeBlock indentedCodeBlock)\n\t{\n\t\taddEmptyLine();\n\t\tcontent.add(new ContentCode(stripLastLn(indentedCodeBlock.getLiteral())));\n\t}\n\n\t@Override\n\tpublic void visit(FencedCodeBlock fencedCodeBlock)\n\t{\n\t\taddEmptyLine();\n\t\tcontent.add(new ContentCode(stripLastLn(fencedCodeBlock.getLiteral())));\n\t}\n\n\t@Override\n\tpublic void visit(HtmlInline htmlInline)\n\t{\n\t\tvar html = htmlInline.getLiteral();\n\n\t\tif (html.startsWith(\"<a href=\\\"\"))\n\t\t{\n\t\t\tvar parent = htmlInline.getParent();\n\t\t\tvar child = parent.getFirstChild();\n\t\t\twhile (child != htmlInline)\n\t\t\t{\n\t\t\t\tchild = child.getNext();\n\t\t\t}\n\t\t\tHtmlInline href = (HtmlInline) child;\n\t\t\tchild = child.getNext();\n\t\t\tif (child instanceof Text text)\n\t\t\t{\n\t\t\t\taddHref(href.getLiteral());\n\t\t\t\ttext.setLiteral(\"\"); // The text is in the hyperlink already, so set it to empty to not have it shown twice\n\t\t\t}\n\t\t}\n\t\telse if (html.startsWith(\"<img \"))\n\t\t{\n\t\t\tvar img = Jsoup.parse(html).selectFirst(\"img\");\n\n\t\t\tif (img != null)\n\t\t\t{\n\t\t\t\tvar data = img.absUrl(\"src\");\n\t\t\t\tif (StringUtils.isNotEmpty(data) && data.startsWith(\"data:\"))\n\t\t\t\t{\n\t\t\t\t\tvar fxImage = getImage(data);\n\n\t\t\t\t\tif (fxImage != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tcontent.add(new ContentImage(fxImage));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse //noinspection StatementWithEmptyBody\n\t\t\tif (html.endsWith(\"</a>\"))\n\t\t{\n\t\t\t// Ignore closing tags\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Let the rest go through verbatim. Problematic tags\n\t\t\t// are already removed upstream using UnHtml.\n\t\t\tcontent.add(new ContentText(html));\n\t\t}\n\t}\n\n\tprivate void addHref(String html)\n\t{\n\t\tvar document = Jsoup.parse(html);\n\t\tvar links = document.getElementsByTag(\"a\");\n\t\tfor (var link : links)\n\t\t{\n\t\t\tvar href = link.attr(\"href\");\n\t\t\tcontent.add(UriFactory.createContent(href, null, uriAction));\n\t\t}\n\t}\n\n\t@Override\n\tpublic void visit(CustomNode customNode)\n\t{\n\t\tif (customNode instanceof Strikethrough strikeThrough)\n\t\t{\n\t\t\tvisit(strikeThrough);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsuper.visit(customNode);\n\t\t}\n\t}\n\n\tpublic void visit(Strikethrough strikeThrough)\n\t{\n\t\tcontent.add(new ContentStrikethrough(getFirstTextChild(strikeThrough).orElse(\"\")));\n\t}\n\n\t@Override\n\tpublic void visit(ThematicBreak thematicBreak)\n\t{\n\t\tif (chatMode)\n\t\t{\n\t\t\tcontent.add(new ContentText(thematicBreak.getLiteral()));\n\t\t}\n\t\telse\n\t\t{\n\t\t\taddEmptyLine();\n\t\t\tcontent.add(new ContentHorizontalRule());\n\t\t\taddEmptyLine();\n\t\t}\n\t}\n\n\t@Override\n\tpublic void visit(Image image)\n\t{\n\t\tvar data = image.getDestination();\n\n\t\tif (StringUtils.isNotBlank(data) && !data.startsWith(\"data:\"))\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar altTextVisitor = new AltTextVisitor();\n\t\timage.accept(altTextVisitor);\n\n\t\tvar fxImage = getImage(data);\n\n\t\tif (fxImage != null)\n\t\t{\n\t\t\tcontent.add(new ContentImage(fxImage));\n\t\t}\n\t}\n\n\tprivate static javafx.scene.image.Image getImage(String data)\n\t{\n\t\tjavafx.scene.image.Image image = null;\n\t\ttry\n\t\t{\n\t\t\timage = new javafx.scene.image.Image(data);\n\t\t\tif (image.isError() || ImageViewUtils.isExaggeratedAspectRatio(image))\n\t\t\t{\n\t\t\t\timage = null;\n\t\t\t}\n\t\t}\n\t\tcatch (IllegalArgumentException e)\n\t\t{\n\t\t\tlog.error(\"Error while loading image\", e);\n\t\t}\n\t\treturn image;\n\t}\n\n\tprivate static Optional<String> getFirstTextChild(Node parent)\n\t{\n\t\tvar node = parent.getFirstChild();\n\t\tif (node instanceof Text text)\n\t\t{\n\t\t\treturn Optional.of(text.getLiteral());\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tprivate static boolean isInTightList(Paragraph paragraph)\n\t{\n\t\tNode parent = paragraph.getParent();\n\t\tif (parent != null)\n\t\t{\n\t\t\tNode gramps = parent.getParent();\n\t\t\tif (gramps instanceof ListBlock list)\n\t\t\t{\n\t\t\t\treturn list.isTight();\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate void addEmptyLine()\n\t{\n\t\tif (!content.isEmpty()) // Don't add a useless empty line at the top\n\t\t{\n\t\t\tcontent.add(new ContentText(\"\\n\"));\n\t\t}\n\t}\n\n\tprivate static boolean mightContainEmojis(String s)\n\t{\n\t\treturn !s.chars().allMatch(c -> c < 128); // Detects non-ASCII\n\t}\n\n\t/**\n\t * Removes the useless \\n from the string. It can produce visual artifacts (for example, empty extra line)\n\t * with some formatting.\n\t *\n\t * @param in the input string\n\t * @return the string without a trailing '\\n', if any\n\t */\n\tprivate static String stripLastLn(String in)\n\t{\n\t\tif (in.endsWith(\"\\n\"))\n\t\t{\n\t\t\treturn in.substring(0, in.length() - 1);\n\t\t}\n\t\treturn in;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/markdown/MarkdownService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.markdown;\n\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.emoji.EmojiService;\nimport io.xeres.ui.support.uri.UriService;\nimport org.commonmark.ext.autolink.AutolinkExtension;\nimport org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;\nimport org.commonmark.parser.Parser;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\nimport java.util.Set;\n\n@Service\npublic class MarkdownService\n{\n\tpublic enum Rendering\n\t{\n\t\t/**\n\t\t * Ignores headings and horizontal rules. Designed for single/short chat lines.\n\t\t */\n\t\tCHAT,\n\n\t\t/**\n\t\t * Treats soft breaks like HTML, that is, converts them to spaces.\n\t\t */\n\t\tTEXT_REFLOW,\n\t}\n\n\tprivate final EmojiService emojiService;\n\tprivate final UriService uriService;\n\tprivate final Parser parser;\n\n\tpublic MarkdownService(EmojiService emojiService, UriService uriService)\n\t{\n\t\tthis.emojiService = emojiService;\n\t\tthis.uriService = uriService;\n\n\t\tparser = Parser.builder()\n\t\t\t\t.extensions(List.of(\n\t\t\t\t\t\tAutolinkExtension.create(),\n\t\t\t\t\t\tStrikethroughExtension.create()))\n\t\t\t\t.build();\n\t}\n\n\t/**\n\t * Parses text and generates a Markdown content from it. A default action will be\n\t * performed when clicking on a link.\n\t *\n\t * @param input the incoming text, possibly annotated with Markdown\n\t * @return a list of content nodes\n\t */\n\tpublic List<Content> parse(String input, Set<Rendering> rendering)\n\t{\n\t\treturn parse(input, rendering, uriService);\n\t}\n\n\t/**\n\t * Parses text and generates a Markdown content from it.\n\t *\n\t * @param input the incoming text, possibly annotated with Markdown\n\t * @param uriAction the action to perform when clicking on a url, can be null for no action\n\t * @return a list of content nodes\n\t */\n\tpublic List<Content> parse(String input, Set<Rendering> rendering, UriAction uriAction)\n\t{\n\t\tvar contentRenderer = new ContentRenderer(emojiService, rendering, uriAction != null ? uriAction : _ -> {\n\t\t});\n\t\treturn contentRenderer.render(parser.parse(input));\n\t}\n\n\tpublic UriService getUriService()\n\t{\n\t\treturn uriService;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/markdown/UriAction.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.markdown;\n\nimport io.xeres.ui.support.uri.Uri;\n\npublic interface UriAction\n{\n\tvoid openUri(Uri uri);\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/notification/NotificationSettings.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.notification;\n\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport org.springframework.stereotype.Service;\n\nimport static io.xeres.ui.support.preference.PreferenceUtils.NOTIFICATIONS;\n\n@Service\npublic class NotificationSettings\n{\n\tprivate static final String ENABLE_BROADCAST = \"EnableBroadcast\";\n\tprivate static final String ENABLE_CONNECTION = \"EnableConnection\";\n\tprivate static final String ENABLE_DISCOVERY = \"EnableDiscovery\";\n\n\tprivate boolean broadcastsEnabled;\n\tprivate boolean connectionEnabled;\n\tprivate boolean discoveryEnabled;\n\n\tprivate boolean loaded;\n\n\tpublic NotificationSettings()\n\t{\n\t}\n\n\tpublic boolean isBroadcastsEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn broadcastsEnabled;\n\t}\n\n\tpublic void setBroadcastsEnabled(boolean broadcastsEnabled)\n\t{\n\t\tthis.broadcastsEnabled = broadcastsEnabled;\n\t}\n\n\tpublic boolean isConnectionEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn connectionEnabled;\n\t}\n\n\tpublic void setConnectionEnabled(boolean connectionEnabled)\n\t{\n\t\tthis.connectionEnabled = connectionEnabled;\n\t}\n\n\tpublic boolean isDiscoveryEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn discoveryEnabled;\n\t}\n\n\tpublic void setDiscoveryEnabled(boolean discoveryEnabled)\n\t{\n\t\tthis.discoveryEnabled = discoveryEnabled;\n\t}\n\n\tprivate void loadIfNeeded()\n\t{\n\t\tif (loaded)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tvar node = PreferenceUtils.getPreferences().node(NOTIFICATIONS);\n\t\tbroadcastsEnabled = node.getBoolean(ENABLE_BROADCAST, true);\n\t\tconnectionEnabled = node.getBoolean(ENABLE_CONNECTION, false);\n\t\tdiscoveryEnabled = node.getBoolean(ENABLE_DISCOVERY, true);\n\n\t\tloaded = true;\n\t}\n\n\tpublic void save()\n\t{\n\t\tvar node = PreferenceUtils.getPreferences().node(NOTIFICATIONS);\n\t\tnode.putBoolean(ENABLE_BROADCAST, broadcastsEnabled);\n\t\tnode.putBoolean(ENABLE_CONNECTION, connectionEnabled);\n\t\tnode.putBoolean(ENABLE_DISCOVERY, discoveryEnabled);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/oembed/OEmbedProvider.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.oembed;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport tools.jackson.core.JacksonException;\nimport tools.jackson.core.type.TypeReference;\nimport tools.jackson.databind.json.JsonMapper;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.regex.Pattern;\n\n/**\n * The original file is <a href=\"https://oembed.com/providers.json\">here</a>. Since entries are free to\n * be added using GitHub's PR, many of them are in there just for the sake of publicity and not\n * popularity. Since checking more than 300 regexp for each URL is expensive, we only keep the\n * most popular entries in the file.\n */\nclass OEmbedProvider\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(OEmbedProvider.class);\n\n\tprivate static final String OEMBED_DATABASE = \"/oembed-providers.json\";\n\n\tprivate Map<Pattern, String> providersMap;\n\n\t@JsonIgnoreProperties(ignoreUnknown = true)\n\tprivate record OEmbedEntry(\n\t\t\tString providerName,\n\t\t\tString providerUrl,\n\t\t\tList<Endpoint> endpoints\n\t)\n\t{\n\t\t@JsonIgnoreProperties(ignoreUnknown = true)\n\t\tprivate record Endpoint(\n\t\t\t\tList<String> schemes,\n\t\t\t\tString url,\n\t\t\t\tboolean discovery\n\t\t)\n\t\t{\n\t\t}\n\t}\n\n\tOEmbedProvider(JsonMapper jsonMapper)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar loadedProviders = jsonMapper.readValue(Objects.requireNonNull(OEmbedProvider.class.getResourceAsStream(OEMBED_DATABASE)),\n\t\t\t\t\tnew TypeReference<List<OEmbedEntry>>()\n\t\t\t\t\t{\n\t\t\t\t\t});\n\n\t\t\tlog.debug(\"Loaded {} oEmbed providers\", loadedProviders.size());\n\n\t\t\tprovidersMap = new HashMap<>();\n\n\t\t\tloadedProviders.forEach(provider -> provider.endpoints().forEach(endpoint -> endpoint.schemes().forEach(scheme -> providersMap.put(Pattern.compile(convertSimplePattern(scheme)), endpoint.url()))));\n\t\t}\n\t\tcatch (JacksonException e)\n\t\t{\n\t\t\tlog.error(\"Couldn't load oEmbed providers database\", e);\n\t\t\tprovidersMap = Map.of();\n\t\t}\n\t}\n\n\tpublic String getOembedForUrl(String url)\n\t{\n\t\treturn providersMap.entrySet().stream()\n\t\t\t\t.filter(provider -> provider.getKey().matcher(url).matches())\n\t\t\t\t.findFirst()\n\t\t\t\t.map(Map.Entry::getValue)\n\t\t\t\t.orElse(\"\");\n\t}\n\n\tprivate static String convertSimplePattern(String pattern)\n\t{\n\t\treturn pattern\n\t\t\t\t.replace(\".\", \"\\\\.\")\n\t\t\t\t.replace(\"?\", \"\\\\?\")\n\t\t\t\t.replace(\"*\", \".*\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/oembed/OEmbedService.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.oembed;\n\nimport io.xeres.ui.properties.UiClientProperties;\nimport org.springframework.stereotype.Service;\nimport tools.jackson.databind.json.JsonMapper;\n\n@Service\npublic class OEmbedService\n{\n\tprivate OEmbedProvider oembedProvider;\n\n\tpublic OEmbedService(UiClientProperties uiClientProperties, JsonMapper jsonMapper)\n\t{\n\t\tif (uiClientProperties.isOEmbed())\n\t\t{\n\t\t\toembedProvider = new OEmbedProvider(jsonMapper);\n\t\t}\n\t}\n\n\tpublic String getOembedForUrl(String url)\n\t{\n\t\tif (oembedProvider == null)\n\t\t{\n\t\t\treturn \"\";\n\t\t}\n\t\treturn oembedProvider.getOembedForUrl(url);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/preference/PreferenceUtils.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.preference;\n\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.util.RemoteUtils;\nimport io.xeres.ui.JavaFxApplication;\nimport io.xeres.ui.model.location.Location;\n\nimport java.util.prefs.Preferences;\n\n/**\n * Utility class to help get a proper preference so that multiple clients can run concurrently.\n * The path to check is:\n * <ul>\n *     <li>Windows: Registry, HKCU\\Software\\JavaSoft\\Prefs (the '/' in front of capital letters in keys and values is an attempt by Sun to make the registry case-sensitive)</li>\n *     <li>Linux: $HOME/.java</li>\n * </ul>\n */\npublic final class PreferenceUtils\n{\n\tpublic static final String CONTACTS = \"Contacts\";\n\tpublic static final String CHAT_ROOMS = \"Chatrooms\";\n\tpublic static final String FORUMS = \"Forums\";\n\tpublic static final String BOARDS = \"Boards\";\n\tpublic static final String CHANNELS = \"Channels\";\n\tpublic static final String NOTIFICATIONS = \"Notifications\";\n\tpublic static final String UPDATE_CHECK = \"UpdateCheck\";\n\tpublic static final String SOUND = \"Sound\";\n\tpublic static final String IMAGE_VIEW = \"ImageView\";\n\n\tprivate static LocationIdentifier locationIdentifier;\n\n\tprivate PreferenceUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void setLocation(Location location)\n\t{\n\t\tlocationIdentifier = location.getLocationIdentifier();\n\t}\n\n\tpublic static Preferences getPreferences()\n\t{\n\t\tvar rootNode = Preferences.userNodeForPackage(JavaFxApplication.class);\n\n\t\tif (RemoteUtils.isRemoteUiClient())\n\t\t{\n\t\t\treturn rootNode.node(\"0\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (locationIdentifier == null)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"Preferences: LocationIdentifier is not set\");\n\t\t\t}\n\t\t\treturn rootNode.node(locationIdentifier.toString());\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/sound/SoundPlayerService.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.sound;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.common.util.OsUtils;\nimport javafx.scene.media.AudioClip;\nimport org.springframework.stereotype.Service;\n\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\n@Service\npublic class SoundPlayerService\n{\n\tprivate final SoundSettings soundSettings;\n\n\tpublic enum SoundType\n\t{\n\t\tMESSAGE,\n\t\tHIGHLIGHT,\n\t\tFRIEND,\n\t\tDOWNLOAD,\n\t\tRINGING\n\t}\n\n\tpublic SoundPlayerService(SoundSettings soundSettings)\n\t{\n\t\tthis.soundSettings = soundSettings;\n\t}\n\n\tpublic void play(SoundType soundType)\n\t{\n\t\tswitch (soundType)\n\t\t{\n\t\t\tcase MESSAGE ->\n\t\t\t{\n\t\t\t\tif (soundSettings.isMessageEnabled())\n\t\t\t\t{\n\t\t\t\t\tplay(soundSettings.getMessageFile());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase HIGHLIGHT ->\n\t\t\t{\n\t\t\t\tif (soundSettings.isHighlightEnabled())\n\t\t\t\t{\n\t\t\t\t\tplay(soundSettings.getHighlightFile());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase FRIEND ->\n\t\t\t{\n\t\t\t\tif (soundSettings.isFriendEnabled())\n\t\t\t\t{\n\t\t\t\t\tplay(soundSettings.getFriendFile());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase DOWNLOAD ->\n\t\t\t{\n\t\t\t\tif (soundSettings.isDownloadEnabled())\n\t\t\t\t{\n\t\t\t\t\tplay(soundSettings.getDownloadFile());\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase RINGING ->\n\t\t\t{\n\t\t\t\tif (soundSettings.isRingingEnabled())\n\t\t\t\t{\n\t\t\t\t\tplay(soundSettings.getRingingFile());\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic AudioClip playRepeated(SoundType soundType)\n\t{\n\t\tswitch (soundType)\n\t\t{\n\t\t\tcase RINGING ->\n\t\t\t{\n\t\t\t\tif (soundSettings.isRingingEnabled())\n\t\t\t\t{\n\t\t\t\t\treturn play(soundSettings.getRingingFile(), true);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\tpublic void play(String file)\n\t{\n\t\tplay(file, false);\n\t}\n\n\tprivate AudioClip play(String file, boolean repeat)\n\t{\n\t\tif (StringUtils.isEmpty(file))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\tvar path = Path.of(file);\n\t\tif (!Files.exists(path) && !path.isAbsolute())\n\t\t{\n\t\t\t// Try to find the file if currentDir is not what we expect.\n\t\t\t// This happens on Windows when auto starting\n\t\t\tvar home = OsUtils.getApplicationHome();\n\t\t\tpath = Path.of(home.toString(), file);\n\n\t\t\t// At some point (Spring probably), the currentDir returned by ApplicationHome() was changed from\n\t\t\t// where the application was installed to 'app'. We have to use the next workaround\n\t\t\t// to detect default paths set in the config prior to that.\n\t\t\tif (!Files.exists(path))\n\t\t\t{\n\t\t\t\tif (file.startsWith(\"app/\"))\n\t\t\t\t{\n\t\t\t\t\tfile = file.substring(\"app/\".length());\n\t\t\t\t\tpath = Path.of(home.toString(), file);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (Files.exists(path))\n\t\t{\n\t\t\tvar clip = new AudioClip(\"file:\" + path.toString().replace(\"\\\\\", \"/\")); // URIs require a '/' for path and Windows uses '\\' for path\n\t\t\tif (repeat)\n\t\t\t{\n\t\t\t\tclip.setCycleCount(AudioClip.INDEFINITE);\n\t\t\t}\n\t\t\tclip.play();\n\t\t\treturn clip;\n\t\t}\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/sound/SoundSettings.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.sound;\n\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.springframework.stereotype.Service;\n\nimport static io.xeres.ui.support.preference.PreferenceUtils.SOUND;\n\n@Service\npublic class SoundSettings\n{\n\tprivate static final String ENABLE_MESSAGE = \"EnableMessage\";\n\tprivate static final String ENABLE_HIGHLIGHT = \"EnableHighlight\";\n\tprivate static final String ENABLE_FRIEND = \"EnableFriend\";\n\tprivate static final String ENABLE_DOWNLOAD = \"EnableDownload\";\n\tprivate static final String ENABLE_RINGING = \"EnableRinging\";\n\n\tprivate static final String MESSAGE_FILE = \"MessageFile\";\n\tprivate static final String HIGHLIGHT_FILE = \"HighlightFile\";\n\tprivate static final String FRIEND_FILE = \"FriendFile\";\n\tprivate static final String DOWNLOAD_FILE = \"DownloadFile\";\n\tprivate static final String RINGING_FILE = \"RingingFile\";\n\n\tprivate boolean messageEnabled;\n\tprivate boolean highlightEnabled;\n\tprivate boolean friendEnabled;\n\tprivate boolean downloadEnabled;\n\tprivate boolean ringingEnabled;\n\n\tprivate String messageFile;\n\tprivate String highlightFile;\n\tprivate String friendFile;\n\tprivate String downloadFile;\n\tprivate String ringingFile;\n\n\tprivate boolean loaded;\n\n\tpublic SoundSettings()\n\t{\n\t}\n\n\tpublic boolean isMessageEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn messageEnabled;\n\t}\n\n\tpublic void setMessageEnabled(boolean messageEnabled)\n\t{\n\t\tthis.messageEnabled = messageEnabled;\n\t}\n\n\tpublic String getMessageFile()\n\t{\n\t\treturn messageFile;\n\t}\n\n\tpublic void setMessageFile(String messageFile)\n\t{\n\t\tthis.messageFile = messageFile;\n\t}\n\n\tpublic boolean isHighlightEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn highlightEnabled;\n\t}\n\n\tpublic void setHighlightEnabled(boolean highlightEnabled)\n\t{\n\t\tthis.highlightEnabled = highlightEnabled;\n\t}\n\n\tpublic String getHighlightFile()\n\t{\n\t\treturn highlightFile;\n\t}\n\n\tpublic void setHighlightFile(String highlightFile)\n\t{\n\t\tthis.highlightFile = highlightFile;\n\t}\n\n\tpublic boolean isFriendEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn friendEnabled;\n\t}\n\n\tpublic void setFriendEnabled(boolean friendEnabled)\n\t{\n\t\tthis.friendEnabled = friendEnabled;\n\t}\n\n\tpublic String getFriendFile()\n\t{\n\t\treturn friendFile;\n\t}\n\n\tpublic void setFriendFile(String friendFile)\n\t{\n\t\tthis.friendFile = friendFile;\n\t}\n\n\tpublic boolean isDownloadEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn downloadEnabled;\n\t}\n\n\tpublic void setDownloadEnabled(boolean downloadEnabled)\n\t{\n\t\tthis.downloadEnabled = downloadEnabled;\n\t}\n\n\tpublic String getDownloadFile()\n\t{\n\t\treturn downloadFile;\n\t}\n\n\tpublic void setDownloadFile(String downloadFile)\n\t{\n\t\tthis.downloadFile = downloadFile;\n\t}\n\n\tpublic boolean isRingingEnabled()\n\t{\n\t\tloadIfNeeded();\n\t\treturn ringingEnabled;\n\t}\n\n\tpublic void setRingingEnabled(boolean ringingEnabled)\n\t{\n\t\tthis.ringingEnabled = ringingEnabled;\n\t}\n\n\tpublic String getRingingFile()\n\t{\n\t\treturn ringingFile;\n\t}\n\n\tpublic void setRingingFile(String ringingFile)\n\t{\n\t\tthis.ringingFile = ringingFile;\n\t}\n\n\tprivate void loadIfNeeded()\n\t{\n\t\tif (loaded)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tvar node = PreferenceUtils.getPreferences().node(SOUND);\n\t\tmessageEnabled = node.getBoolean(ENABLE_MESSAGE, false);\n\t\thighlightEnabled = node.getBoolean(ENABLE_HIGHLIGHT, false);\n\t\tfriendEnabled = node.getBoolean(ENABLE_FRIEND, false);\n\t\tdownloadEnabled = node.getBoolean(ENABLE_DOWNLOAD, false);\n\t\tringingEnabled = node.getBoolean(ENABLE_RINGING, true); // Important enough to be enabled by default\n\n\t\tvar prefixPath = SystemUtils.IS_OS_LINUX ? \"/opt/xeres/lib/\" : \"\";\n\n\t\tmessageFile = node.get(MESSAGE_FILE, prefixPath + \"sounds/message-notification-190034.mp3\");\n\t\thighlightFile = node.get(HIGHLIGHT_FILE, prefixPath + \"sounds/notification-4-126507.mp3\");\n\t\tfriendFile = node.get(FRIEND_FILE, prefixPath + \"sounds/notification-20-270145.mp3\");\n\t\tdownloadFile = node.get(DOWNLOAD_FILE, prefixPath + \"sounds/achive-sound-132273.mp3\");\n\t\tringingFile = node.get(RINGING_FILE, prefixPath + \"sounds/ringtone-023-376906.mp3\");\n\n\t\tloaded = true;\n\t}\n\n\tpublic void save()\n\t{\n\t\tvar node = PreferenceUtils.getPreferences().node(SOUND);\n\t\tnode.putBoolean(ENABLE_MESSAGE, messageEnabled);\n\t\tnode.putBoolean(ENABLE_HIGHLIGHT, highlightEnabled);\n\t\tnode.putBoolean(ENABLE_FRIEND, friendEnabled);\n\t\tnode.putBoolean(ENABLE_DOWNLOAD, downloadEnabled);\n\t\tnode.putBoolean(ENABLE_RINGING, ringingEnabled);\n\n\t\tnode.put(MESSAGE_FILE, messageFile);\n\t\tnode.put(HIGHLIGHT_FILE, highlightFile);\n\t\tnode.put(FRIEND_FILE, friendFile);\n\t\tnode.put(DOWNLOAD_FILE, downloadFile);\n\t\tnode.put(RINGING_FILE, ringingFile);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/splash/SplashService.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.splash;\n\nimport org.springframework.stereotype.Service;\n\nimport java.awt.*;\nimport java.util.ResourceBundle;\n\n@Service\npublic final class SplashService\n{\n\tpublic enum Status\n\t{\n\t\tDATABASE,\n\t\tNETWORK\n\t}\n\n\tprivate final ResourceBundle bundle;\n\n\tprivate SplashScreen splashScreen;\n\tprivate Graphics2D g2d;\n\tprivate Dimension dimension;\n\n\tprivate static final int LOADING_TEXT_DISTANCE = 20;\n\tprivate static final int MARGINS = 2;\n\tprivate static final int BACKGROUND_COLOR = 0x414242;\n\n\tpublic SplashService(ResourceBundle bundle)\n\t{\n\t\tthis.bundle = bundle;\n\n\t\ttry\n\t\t{\n\t\t\tsplashScreen = SplashScreen.getSplashScreen();\n\t\t}\n\t\tcatch (UnsupportedOperationException _)\n\t\t{\n\t\t\t// No splash screen supported\n\t\t}\n\n\t\tif (splashScreen != null && splashScreen.isVisible())\n\t\t{\n\t\t\tg2d = splashScreen.createGraphics();\n\t\t\tdimension = splashScreen.getSize();\n\n\t\t\tg2d.setBackground(new Color(BACKGROUND_COLOR));\n\t\t\tg2d.setColor(Color.BLACK);\n\t\t\tg2d.setFont(g2d.getFont().deriveFont(Font.BOLD));\n\t\t\tg2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);\n\t\t}\n\t}\n\n\tpublic void status(Status status)\n\t{\n\t\tif (g2d != null)\n\t\t{\n\t\t\tvar y = dimension.getHeight() - LOADING_TEXT_DISTANCE;\n\n\t\t\tg2d.clearRect(MARGINS, (int) y, (int) dimension.getWidth() - MARGINS * 2, LOADING_TEXT_DISTANCE - MARGINS);\n\t\t\tdrawStringCentered(getDescriptionFromStatus(status) + \"…\", (int) y);\n\t\t\tsplashScreen.update();\n\t\t}\n\t}\n\n\tprivate String getDescriptionFromStatus(Status status)\n\t{\n\t\treturn switch (status)\n\t\t{\n\t\t\tcase DATABASE -> bundle.getString(\"splash.status.database\");\n\t\t\tcase NETWORK -> bundle.getString(\"splash.status.network\");\n\t\t};\n\t}\n\n\tpublic void close()\n\t{\n\t\tif (splashScreen != null)\n\t\t{\n\t\t\t// We don't need the splash screen anymore, so let the GC collect it\n\t\t\tsplashScreen.close();\n\t\t\tg2d = null;\n\t\t\tdimension = null;\n\t\t\tsplashScreen = null;\n\t\t}\n\t}\n\n\tprivate void drawStringCentered(String s, int y)\n\t{\n\t\tvar metrics = g2d.getFontMetrics();\n\t\tvar x = ((int) dimension.getWidth() - metrics.stringWidth(s)) / 2;\n\n\t\tg2d.drawString(s, x, y + metrics.getAscent());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/theme/AppTheme.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.theme;\n\nimport atlantafx.base.theme.*;\n\nimport java.util.Arrays;\n\npublic enum AppTheme\n{\n\tPRIMER_LIGHT(\"Primer Light\", PrimerLight.class, false),\n\tPRIMER_DARK(\"Primer Dark\", PrimerDark.class, true),\n\tNORD_LIGHT(\"Nord Light\", NordLight.class, false),\n\tNORD_DARK(\"Nord Dark\", NordDark.class, true),\n\tCUPERTINO_LIGHT(\"Cupertino Light\", CupertinoLight.class, false),\n\tCUPERTINO_DARK(\"Cupertino Dark\", CupertinoDark.class, true),\n\tDRACULA(\"Dracula\", Dracula.class, true);\n\n\tprivate final String name;\n\tprivate final Class<? extends Theme> themeClass;\n\tprivate final boolean isDark;\n\n\tAppTheme(String name, Class<? extends Theme> themeClass, boolean isDark)\n\t{\n\t\tthis.name = name;\n\t\tthis.themeClass = themeClass;\n\t\tthis.isDark = isDark;\n\t}\n\n\tpublic String getName()\n\t{\n\t\treturn name;\n\t}\n\n\tpublic Class<? extends Theme> getThemeClass()\n\t{\n\t\treturn themeClass;\n\t}\n\n\tpublic boolean isDark()\n\t{\n\t\treturn isDark;\n\t}\n\n\tpublic static AppTheme findByName(String name)\n\t{\n\t\treturn Arrays.stream(values()).filter(appTheme -> appTheme.getName().equals(name)).findFirst().orElse(null);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn name;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/theme/AppThemeManager.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.theme;\n\nimport io.xeres.common.properties.StartupProperties;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.window.UiNativeWindow;\nimport javafx.application.Application;\nimport javafx.application.ColorScheme;\nimport javafx.application.Platform;\nimport org.springframework.stereotype.Component;\n\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.Optional;\nimport java.util.prefs.Preferences;\n\nimport static io.xeres.common.properties.StartupProperties.Property.UI;\n\n@Component\npublic class AppThemeManager\n{\n\tpublic static final String NODE_APPLICATION = \"Application\";\n\tpublic static final String KEY_THEME = \"Theme\";\n\n\tprivate AppTheme defaultTheme;\n\n\tpublic AppTheme getCurrentTheme()\n\t{\n\t\tif (defaultTheme == null)\n\t\t{\n\t\t\tdefaultTheme = getDefaultTheme();\n\t\t}\n\n\t\tPreferences rootPreferences;\n\t\ttry\n\t\t{\n\t\t\trootPreferences = PreferenceUtils.getPreferences();\n\t\t}\n\t\tcatch (IllegalStateException _)\n\t\t{\n\t\t\t// This can be called when the preferences aren't fully setup (no LocationIdentifier known yet)\n\t\t\t// so in that case we simply use the default theme.\n\t\t\treturn defaultTheme;\n\t\t}\n\n\t\tvar preferences = rootPreferences.node(NODE_APPLICATION);\n\t\treturn Optional.ofNullable(AppTheme.findByName(preferences.get(KEY_THEME, String.valueOf(defaultTheme)))).orElse(defaultTheme);\n\t}\n\n\tpublic void applyCurrentTheme()\n\t{\n\t\tapplyTheme(getCurrentTheme());\n\t}\n\n\tpublic void changeTheme(AppTheme appTheme)\n\t{\n\t\tapplyTheme(appTheme);\n\t\tUiNativeWindow.setDarkModeAll(appTheme.isDark());\n\t\tsaveCurrentTheme(appTheme);\n\t}\n\n\tprivate static AppTheme getDefaultTheme()\n\t{\n\t\t// If we start without a UI, the toolkit won't run,\n\t\t// and we can't use getPreferences().\n\t\tif (!StartupProperties.getBoolean(UI, true))\n\t\t{\n\t\t\treturn AppTheme.PRIMER_LIGHT;\n\t\t}\n\t\treturn switch (Platform.getPreferences().getColorScheme())\n\t\t{\n\t\t\tcase ColorScheme.LIGHT -> AppTheme.PRIMER_LIGHT;\n\t\t\tcase ColorScheme.DARK -> AppTheme.DRACULA;\n\t\t};\n\t}\n\n\tprivate static void applyTheme(AppTheme appTheme)\n\t{\n\t\ttry\n\t\t{\n\t\t\tApplication.setUserAgentStylesheet(appTheme.getThemeClass().getDeclaredConstructor().newInstance().getUserAgentStylesheet());\n\t\t}\n\t\tcatch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t}\n\n\tprivate void saveCurrentTheme(AppTheme appTheme)\n\t{\n\t\tvar preferences = PreferenceUtils.getPreferences().node(NODE_APPLICATION);\n\t\tpreferences.put(KEY_THEME, appTheme.getName());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/tray/TrayService.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.tray;\n\nimport io.xeres.common.AppName;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.tray.TrayNotificationType;\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.client.NotificationClient;\nimport io.xeres.ui.support.notification.NotificationSettings;\nimport io.xeres.ui.support.sound.SoundPlayerService;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.Platform;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.stereotype.Service;\nimport reactor.core.Disposable;\n\nimport java.awt.*;\nimport java.awt.event.MouseAdapter;\nimport java.awt.event.MouseEvent;\nimport java.text.MessageFormat;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\n\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\n@Service\npublic class TrayService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(TrayService.class);\n\n\tprivate SystemTray systemTray;\n\tprivate TrayIcon trayIcon;\n\tprivate boolean hasSystemTray;\n\n\tprivate Image image;\n\tprivate Image eventImage;\n\tprivate Image busyImage;\n\n\tprivate String tooltipTitle;\n\n\tprivate final WindowManager windowManager;\n\tprivate final NotificationClient notificationClient;\n\tprivate final ConfigClient configClient;\n\tprivate final NotificationSettings notificationSettings;\n\tprivate final SoundPlayerService soundPlayerService;\n\tprivate final ResourceBundle bundle;\n\n\tprivate Disposable availabilityNotificationDisposable;\n\n\tpublic TrayService(WindowManager windowManager, NotificationClient notificationClient, ConfigClient configClient, NotificationSettings notificationSettings, SoundPlayerService soundPlayerService, ResourceBundle bundle)\n\t{\n\t\tthis.windowManager = windowManager;\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.configClient = configClient;\n\t\tthis.notificationSettings = notificationSettings;\n\t\tthis.soundPlayerService = soundPlayerService;\n\t\tthis.bundle = bundle;\n\t}\n\n\tpublic void addSystemTray(String title)\n\t{\n\t\tif (hasSystemTray)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\t// Doesn't work on MacOS: prevents the app from exiting properly, so, disabled\n\t\tif (!SystemTray.isSupported() || SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\tlog.error(\"System tray not supported on that platform\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar launchItem = new MenuItem(MessageFormat.format(bundle.getString(\"tray.open\"), AppName.NAME));\n\t\tlaunchItem.setFont(Font.decode(null).deriveFont(Font.BOLD));\n\t\tlaunchItem.addActionListener(_ ->\n\t\t\t\twindowManager.openMain(null, null, false));\n\n\t\tvar peersMenu = new Menu(bundle.getString(\"tray.peers\") + \" >\");\n\t\tpeersMenu.setEnabled(false);\n\n\t\tvar statusMenu = new Menu(bundle.getString(\"tray.status\") + \" >\");\n\t\tstatusMenu.add(createStateMenuItem(Availability.AVAILABLE));\n\t\tstatusMenu.add(createStateMenuItem(Availability.BUSY));\n\t\tstatusMenu.add(createStateMenuItem(Availability.AWAY));\n\n\t\tvar exitItem = new MenuItem(bundle.getString(\"exit\"));\n\t\texitItem.addActionListener(_ -> exitApplication());\n\n\t\tvar popupMenu = new PopupMenu();\n\t\tpopupMenu.add(launchItem);\n\t\tpopupMenu.add(peersMenu);\n\t\tpopupMenu.add(statusMenu);\n\t\tpopupMenu.addSeparator();\n\t\tpopupMenu.add(exitItem);\n\n\t\timage = Toolkit.getDefaultToolkit().getImage(TrayService.class.getResource(\"/image/trayicon.png\"));\n\t\teventImage = Toolkit.getDefaultToolkit().getImage(TrayService.class.getResource(\"/image/trayicon_event.png\"));\n\t\tbusyImage = Toolkit.getDefaultToolkit().getImage(TrayService.class.getResource(\"/image/trayicon_busy.png\"));\n\n\t\ttooltipTitle = title;\n\t\ttrayIcon = new TrayIcon(image, tooltipTitle, popupMenu);\n\t\ttrayIcon.setImageAutoSize(true);\n\n\t\tsystemTray = SystemTray.getSystemTray();\n\n\t\ttrayIcon.addMouseListener(createContextMenuMouseAdapter());\n\n\t\tsetStatus(Availability.AVAILABLE);\n\t\tsetupAvailabilityNotifications();\n\n\t\ttry\n\t\t{\n\t\t\tsystemTray.add(trayIcon);\n\t\t\thasSystemTray = true;\n\t\t}\n\t\tcatch (AWTException e)\n\t\t{\n\t\t\tlog.error(\"Failed to put system tray: {}\", e.getMessage(), e);\n\t\t}\n\t}\n\n\tprivate MenuItem createStateMenuItem(Availability availability)\n\t{\n\t\tvar menuItem = new MenuItem(availability.toString());\n\t\tmenuItem.addActionListener(_ -> configClient.changeAvailability(availability).subscribe());\n\t\treturn menuItem;\n\t}\n\n\t/**\n\t * Exits the application in a clean way.\n\t */\n\tpublic void exitApplication()\n\t{\n\t\tremoveSystemTray();\n\t\twindowManager.closeAllWindowsAndExit();\n\t}\n\n\tprivate void setupAvailabilityNotifications()\n\t{\n\t\tavailabilityNotificationDisposable = notificationClient.getAvailabilityNotifications()\n\t\t\t\t.doOnNext(sse -> {\n\t\t\t\t\tObjects.requireNonNull(sse.data());\n\n\t\t\t\t\t// Don't chat with oneself\n\t\t\t\t\tif (sse.data().locationId() == 1L)\n\t\t\t\t\t{\n\t\t\t\t\t\tsetStatus(sse.data().availability());\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (sse.data().availability() == Availability.OFFLINE)\n\t\t\t\t\t{\n\t\t\t\t\t\tremovePeer(sse.data().locationId());\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\taddPeer(sse.data().locationId(), sse.data().profileName(), sse.data().locationName());\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate int findPeerItemIndex(Menu peersMenu, long locationId)\n\t{\n\t\tfor (var i = 0; i < peersMenu.getItemCount(); i++)\n\t\t{\n\t\t\tif (peersMenu.getItem(i).getName().equals(String.valueOf(locationId)))\n\t\t\t{\n\t\t\t\treturn i;\n\t\t\t}\n\t\t}\n\t\treturn -1;\n\t}\n\n\tprivate int findInsertionPoint(Menu peersMenu, String name)\n\t{\n\t\tfor (var i = 0; i < peersMenu.getItemCount(); i++)\n\t\t{\n\t\t\tif (peersMenu.getItem(i).getName().compareTo(name) > 0)\n\t\t\t{\n\t\t\t\treturn i;\n\t\t\t}\n\t\t}\n\t\treturn 0;\n\t}\n\n\tprivate void addPeer(long locationId, String profileName, String locationName)\n\t{\n\t\tvar peersMenu = (Menu) trayIcon.getPopupMenu().getItem(1);\n\t\tpeersMenu.setEnabled(true);\n\n\t\tif (findPeerItemIndex(peersMenu, locationId) != -1)\n\t\t{\n\t\t\treturn; // Already present\n\t\t}\n\n\t\tvar peerItem = new MenuItem(String.format(\"%s (%s)\", profileName, locationName));\n\t\tpeerItem.setName(String.valueOf(locationId));\n\t\tpeerItem.addActionListener(_ -> windowManager.openMessaging(locationId));\n\t\tpeersMenu.insert(peerItem, findInsertionPoint(peersMenu, peerItem.getName()));\n\t}\n\n\tprivate void removePeer(long locationId)\n\t{\n\t\tvar peersMenu = (Menu) trayIcon.getPopupMenu().getItem(1);\n\n\t\tvar index = findPeerItemIndex(peersMenu, locationId);\n\t\tif (index != -1)\n\t\t{\n\t\t\tpeersMenu.remove(index);\n\t\t}\n\t\tif (peersMenu.getItemCount() == 0)\n\t\t{\n\t\t\tpeersMenu.setEnabled(false);\n\t\t}\n\t}\n\n\tprivate void setStatus(Availability availability)\n\t{\n\t\tvar statusMenu = (Menu) trayIcon.getPopupMenu().getItem(2);\n\n\t\tif (availability != Availability.OFFLINE)\n\t\t{\n\t\t\tsetStatusItemDisabled(statusMenu, availability.ordinal());\n\t\t\tsetBusy(availability == Availability.BUSY);\n\t\t}\n\t}\n\n\tprivate void setStatusItemDisabled(Menu statusMenu, int index)\n\t{\n\t\tfor (var i = 0; i < statusMenu.getItemCount(); i++)\n\t\t{\n\t\t\tvar statusItem = statusMenu.getItem(i);\n\t\t\tstatusItem.setEnabled(i != index);\n\t\t}\n\t}\n\n\tprivate MouseAdapter createContextMenuMouseAdapter()\n\t{\n\t\treturn new MouseAdapter()\n\t\t{\n\t\t\t@Override\n\t\t\tpublic void mouseReleased(MouseEvent e)\n\t\t\t{\n\t\t\t\tif (e.getButton() == MouseEvent.BUTTON1)\n\t\t\t\t{\n\t\t\t\t\tPlatform.runLater(() ->\n\t\t\t\t\t{\n\t\t\t\t\t\tvar stage = windowManager.getMainStage();\n\n\t\t\t\t\t\t// Do not hide an iconified stage otherwise\n\t\t\t\t\t\t// it's not trivial to recover. We don't actually really\n\t\t\t\t\t\t// iconify in the app so this is defensive code.\n\t\t\t\t\t\tif (stage.isIconified())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tstage.setIconified(false);\n\t\t\t\t\t\t\tclearEvent();\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif (stage.isShowing())\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tstage.hide();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Yet another weird workaround that allows to\n\t\t\t\t\t\t\t\t// remember the size before iconifying the\n\t\t\t\t\t\t\t\t// window.\n\t\t\t\t\t\t\t\tstage.setX(stage.getX());\n\t\t\t\t\t\t\t\tstage.setY(stage.getY());\n\t\t\t\t\t\t\t\tstage.setWidth(stage.getWidth());\n\t\t\t\t\t\t\t\tstage.setHeight(stage.getHeight());\n\t\t\t\t\t\t\t\tstage.show();\n\t\t\t\t\t\t\t\tclearEvent();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tsuper.mouseClicked(e);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t}\n\n\tpublic boolean hasSystemTray()\n\t{\n\t\treturn hasSystemTray;\n\t}\n\n\tpublic void showNotification(TrayNotificationType type, String message)\n\t{\n\t\tif (hasSystemTray && isNotificationAllowed(type))\n\t\t{\n\t\t\ttrayIcon.displayMessage(AppName.NAME, message, TrayIcon.MessageType.NONE);\n\t\t\tif (type == TrayNotificationType.CONNECTION)\n\t\t\t{\n\t\t\t\tsoundPlayerService.play(SoundPlayerService.SoundType.FRIEND);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void setTooltip(String message)\n\t{\n\t\tif (hasSystemTray)\n\t\t{\n\t\t\ttrayIcon.setToolTip(isNotBlank(message) ? (tooltipTitle + \"\\n\" + message) : tooltipTitle);\n\t\t}\n\t}\n\n\tprivate void setBusy(boolean busy)\n\t{\n\t\tif (busy && trayIcon.getImage() != busyImage)\n\t\t{\n\t\t\ttrayIcon.setImage(busyImage);\n\t\t}\n\t\telse if (!busy && trayIcon.getImage() != image)\n\t\t{\n\t\t\ttrayIcon.setImage(image);\n\t\t}\n\t}\n\n\tpublic void clearEvent()\n\t{\n\t\tif (hasSystemTray && trayIcon.getImage() != busyImage)\n\t\t{\n\t\t\ttrayIcon.setImage(image);\n\t\t}\n\t}\n\n\tpublic void setEventIfIconified()\n\t{\n\t\tif (hasSystemTray && trayIcon.getImage() != busyImage)\n\t\t{\n\t\t\ttrayIcon.setImage(eventImage);\n\t\t}\n\t}\n\n\tprivate boolean isNotificationAllowed(TrayNotificationType type)\n\t{\n\t\treturn switch (type)\n\t\t{\n\t\t\tcase BROADCAST -> notificationSettings.isBroadcastsEnabled();\n\t\t\tcase CONNECTION -> notificationSettings.isConnectionEnabled();\n\t\t\tcase DISCOVERY -> notificationSettings.isDiscoveryEnabled();\n\t\t};\n\t}\n\n\tprivate void removeSystemTray()\n\t{\n\t\tif (hasSystemTray)\n\t\t{\n\t\t\tavailabilityNotificationDisposable.dispose();\n\t\t\tsystemTray.remove(trayIcon);\n\t\t\thasSystemTray = false;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/unread/UnreadService.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.unread;\n\nimport io.xeres.ui.event.UnreadEvent;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\n\n@Service\npublic class UnreadService\n{\n\tprivate final ApplicationEventPublisher eventPublisher;\n\n\tpublic UnreadService(ApplicationEventPublisher eventPublisher)\n\t{\n\t\tthis.eventPublisher = eventPublisher;\n\t}\n\n\tpublic void sendUnreadEvent(UnreadEvent.Element element, boolean unread)\n\t{\n\t\teventPublisher.publishEvent(new UnreadEvent(element, unread));\n\t}\n\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/updater/UpdateService.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.updater;\n\nimport io.xeres.common.AppName;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.client.update.ReleaseAsset;\nimport io.xeres.ui.client.update.ReleaseResponse;\nimport io.xeres.ui.client.update.UpdateClient;\nimport io.xeres.ui.controller.MainWindowController;\nimport io.xeres.ui.support.tray.TrayService;\nimport io.xeres.ui.support.util.UiUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport jakarta.annotation.Nullable;\nimport javafx.application.HostServices;\nimport javafx.application.Platform;\nimport javafx.scene.control.*;\nimport javafx.scene.layout.Region;\nimport javafx.stage.Stage;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.boot.info.BuildProperties;\nimport org.springframework.stereotype.Service;\nimport reactor.core.scheduler.Schedulers;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.text.MessageFormat;\nimport java.util.ResourceBundle;\nimport java.util.prefs.Preferences;\n\nimport static java.lang.Boolean.TRUE;\n\n@Service\npublic class UpdateService\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UpdateService.class);\n\n\tprivate static final String XERES_DOWNLOAD_URL = \"https://xeres.io/download\";\n\n\tprivate static final String WINDOWS_INSTALLER_EXTENSION = \".msi\";\n\n\tprivate VersionChecker versionChecker;\n\n\tprivate final MainWindowController mainWindowController;\n\tprivate final UpdateClient updateClient;\n\tprivate final ConfigClient configClient;\n\tprivate final BuildProperties buildProperties;\n\tprivate final HostServices hostServices;\n\tprivate final TrayService trayService;\n\tprivate final ResourceBundle bundle;\n\n\tpublic UpdateService(MainWindowController mainWindowController, UpdateClient updateClient, ConfigClient configClient, BuildProperties buildProperties, @SuppressWarnings(\"SpringJavaInjectionPointsAutowiringInspection\") @Nullable HostServices hostServices, TrayService trayService, ResourceBundle bundle)\n\t{\n\t\tthis.mainWindowController = mainWindowController;\n\t\tthis.updateClient = updateClient;\n\t\tthis.configClient = configClient;\n\t\tthis.buildProperties = buildProperties;\n\t\tthis.hostServices = hostServices;\n\t\tthis.trayService = trayService;\n\t\tthis.bundle = bundle;\n\t}\n\n\tpublic void startBackgroundChecksIfEnabled()\n\t{\n\t\tversionChecker = new VersionChecker();\n\t\tversionChecker.scheduleVersionCheck(this::checkForUpdateInBackground);\n\t}\n\n\tpublic void checkForUpdate()\n\t{\n\t\tupdateClient.getLatestVersion()\n\t\t\t\t.doOnSuccess(releaseResponse -> Platform.runLater(() -> {\n\t\t\t\t\tassert releaseResponse != null;\n\t\t\t\t\tif (versionChecker.isVersionMoreRecent(releaseResponse.tagName(), buildProperties.getVersion(), false))\n\t\t\t\t\t{\n\t\t\t\t\t\tUiUtils.showAlertConfirm(MessageFormat.format(bundle.getString(\"update.new-version\"), releaseResponse.tagName().substring(1)), () -> download(releaseResponse));\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tUiUtils.showAlert(Alert.AlertType.INFORMATION, bundle.getString(\"update.latest-already\"));\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t.subscribe();\n\t}\n\n\tpublic void skipUpdate(String tagName)\n\t{\n\t\tversionChecker.skipUpdate(tagName);\n\t}\n\n\tpublic boolean isAutomaticallyCheckingForUpdates(Preferences preferences)\n\t{\n\t\treturn VersionChecker.isCheckForUpdates(preferences);\n\t}\n\n\tpublic void setAutomaticCheckForUpdates(Preferences preferences, boolean check)\n\t{\n\t\tVersionChecker.setCheckForUpdates(preferences, check);\n\t}\n\n\tprivate void checkForUpdateInBackground()\n\t{\n\t\tupdateClient.getLatestVersion()\n\t\t\t\t.doOnSuccess(releaseResponse -> Platform.runLater(() -> {\n\t\t\t\t\tassert releaseResponse != null;\n\t\t\t\t\tif (versionChecker.isVersionMoreRecent(releaseResponse.tagName(), buildProperties.getVersion(), true))\n\t\t\t\t\t{\n\t\t\t\t\t\tmainWindowController.showUpdate(MessageFormat.format(bundle.getString(\"update.new-version-auto\"), releaseResponse.tagName().substring(1)), releaseResponse.tagName(), () -> download(releaseResponse));\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void download(ReleaseResponse releaseResponse)\n\t{\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\tvar url = releaseResponse.assets().stream()\n\t\t\t\t\t.filter(asset -> asset.name().startsWith(AppName.NAME) && asset.name().endsWith(WINDOWS_INSTALLER_EXTENSION))\n\t\t\t\t\t.findAny()\n\t\t\t\t\t.map(ReleaseAsset::url)\n\t\t\t\t\t.orElse(null);\n\n\t\t\tvar signingUrl = releaseResponse.assets().stream()\n\t\t\t\t\t.filter(asset -> asset.name().startsWith(AppName.NAME) && asset.name().endsWith(WINDOWS_INSTALLER_EXTENSION + \".sig\"))\n\t\t\t\t\t.findAny()\n\t\t\t\t\t.map(ReleaseAsset::url)\n\t\t\t\t\t.orElse(null);\n\n\t\t\tif (url != null && signingUrl != null)\n\t\t\t{\n\t\t\t\tdownload(url, signingUrl);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tlog.debug(\"Couldn't download url and/or signing url\");\n\t\t\t\tUiUtils.showAlert(Alert.AlertType.ERROR, bundle.getString(\"update.download-failure\"));\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tshowDownloadUrl();\n\t\t}\n\t}\n\n\tprivate void download(String url, String signingUrl)\n\t{\n\t\tlog.debug(\"Downloading {}\", url);\n\t\tPath tempFile;\n\t\ttry\n\t\t{\n\t\t\ttempFile = Files.createTempFile(AppName.NAME + \"_update_\", WINDOWS_INSTALLER_EXTENSION);\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\n\t\tvar progressBar = new ProgressBar(0);\n\t\tvar dialogPane = new DialogPane();\n\t\tdialogPane.setHeaderText(bundle.getString(\"update.download-file\"));\n\t\tdialogPane.getButtonTypes().addAll(ButtonType.CANCEL); // XXX: how can I make it do something?\n\t\tdialogPane.setContent(progressBar);\n\t\tdialogPane.setMinHeight(Region.USE_PREF_SIZE);\n\t\tvar dialog = new Dialog<Void>();\n\t\tvar defaultOwnerWindow = WindowManager.getDefaultOwnerWindow();\n\t\tif (defaultOwnerWindow != null)\n\t\t{\n\t\t\tdialog.initOwner(defaultOwnerWindow);\n\t\t}\n\t\tdialog.setDialogPane(dialogPane);\n\t\tdialog.setWidth(320);\n\n\t\tdialog.setTitle(bundle.getString(\"update.download.title\"));\n\n\t\tvar stage = (Stage) dialog.getDialogPane().getScene().getWindow();\n\t\tUiUtils.setDefaultIcon(stage);\n\t\tUiUtils.setDefaultStyle(stage.getScene());\n\n\t\tdialog.show();\n\n\t\tupdateClient.downloadFile(signingUrl)\n\t\t\t\t.publishOn(Schedulers.boundedElastic())\n\t\t\t\t.doOnSuccess(signature -> updateClient.downloadFileWithProgress(url, tempFile, progress -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tprogressBar.setProgress(progress.getProgress());\n\t\t\t\t\t\t\tlog.debug(\"Progress: {}\", progress.getProgress());\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.doOnComplete(() -> Platform.runLater(() -> {\n\t\t\t\t\t\t\tlog.debug(\"Download complete\");\n\t\t\t\t\t\t\tdialogPane.getButtonTypes().clear();\n\t\t\t\t\t\t\tvar installButtonType = new ButtonType(bundle.getString(\"update.download.install\"));\n\t\t\t\t\t\t\tdialogPane.getButtonTypes().addAll(installButtonType);\n\n\t\t\t\t\t\t\tvar installButton = dialogPane.lookupButton(installButtonType);\n\t\t\t\t\t\t\tinstallButton.setDisable(true);\n\t\t\t\t\t\t\tdialogPane.setHeaderText(bundle.getString(\"update.download.verifying\"));\n\t\t\t\t\t\t\tprogressBar.setProgress(-1);\n\n\t\t\t\t\t\t\tUiUtils.setAbsent(progressBar);\n\n\t\t\t\t\t\t\tconfigClient.verifyUpdate(tempFile.toAbsolutePath().toString(), signature)\n\t\t\t\t\t\t\t\t\t.doOnSuccess(signingResult -> Platform.runLater(() -> {\n\t\t\t\t\t\t\t\t\t\tif (TRUE.equals(signingResult))\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tlog.debug(\"File verified successfully\");\n\t\t\t\t\t\t\t\t\t\t\tdialogPane.setHeaderText(bundle.getString(\"update.download.install-ready\"));\n\t\t\t\t\t\t\t\t\t\t\tinstallButton.setDisable(false);\n\t\t\t\t\t\t\t\t\t\t\tinstallButton.setOnMouseReleased(_ -> install(tempFile.toFile()));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tdialogPane.setHeaderText(bundle.getString(\"update.download-verification-failed\"));\n\t\t\t\t\t\t\t\t\t\t\tlog.debug(\"Verification failed!\");\n\t\t\t\t\t\t\t\t\t\t\t// XXX: set button as either retry or close... or showDownloadUrl() ?\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t.subscribe();\n\t\t\t\t\t\t}))\n\t\t\t\t\t\t.doOnError(UiUtils::webAlertError)\n\t\t\t\t\t\t.subscribe())\n\t\t\t\t.subscribe();\n\t}\n\n\tprivate void showDownloadUrl()\n\t{\n\t\tif (hostServices != null)\n\t\t{\n\t\t\thostServices.showDocument(XERES_DOWNLOAD_URL);\n\t\t}\n\t}\n\n\tprivate void install(File file)\n\t{\n\t\ttry\n\t\t{\n\t\t\tOsUtils.shellExecuteAsync(\"msiexec\", \"/i\", file.getAbsolutePath(), \"/qb\"); // Run with minimal UI (progress bar, upgrade without asking)\n\t\t}\n\t\tcatch (IllegalStateException e)\n\t\t{\n\t\t\t// In case the executable fails to run for some reason (and it happened once!), show the download URL directly.\n\t\t\tUiUtils.webAlertError(e, this::showDownloadUrl);\n\t\t\treturn;\n\t\t}\n\t\ttrayService.exitApplication();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/updater/Version.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.updater;\n\nrecord Version(int major, int minor, int patch) implements Comparable<Version>\n{\n\t@Override\n\tpublic int compareTo(Version o)\n\t{\n\t\tif (major < o.major)\n\t\t{\n\t\t\treturn -1;\n\t\t}\n\t\telse if (major > o.major)\n\t\t{\n\t\t\treturn 1;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (minor < o.minor)\n\t\t\t{\n\t\t\t\treturn -1;\n\t\t\t}\n\t\t\telse if (minor > o.minor)\n\t\t\t{\n\t\t\t\treturn 1;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn Integer.compare(patch, o.patch);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic boolean isNotARelease()\n\t{\n\t\treturn major == 0 && minor == 0 && patch == 0;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn major + \".\" + minor + \".\" + patch;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/updater/VersionCheckTask.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.updater;\n\nimport java.util.TimerTask;\n\nclass VersionCheckTask extends TimerTask\n{\n\tprivate final Runnable runnable;\n\n\tpublic VersionCheckTask(Runnable runnable)\n\t{\n\t\tthis.runnable = runnable;\n\t}\n\n\t@Override\n\tpublic void run()\n\t{\n\t\trunnable.run();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/updater/VersionChecker.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.updater;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.ui.support.preference.PreferenceUtils;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Timer;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.prefs.Preferences;\nimport java.util.regex.Pattern;\n\nimport static io.xeres.ui.support.preference.PreferenceUtils.UPDATE_CHECK;\n\n/**\n * Compares if there's a new version and if the user decided to skip it.\n */\nclass VersionChecker\n{\n\tprivate static final Pattern VERSION_PATTERN = Pattern.compile(\"^v(\\\\d{1,5})\\\\.(\\\\d{1,5})\\\\.(\\\\d{1,5})$\");\n\n\tprivate static final String KEY_LAST_CHECK = \"LastCheck\";\n\tprivate static final String KEY_ENABLED = \"Enabled\";\n\tprivate static final String KEY_SKIP = \"Skip\";\n\n\t/**\n\t * How long to wait between automated version checks.\n\t */\n\tprivate static final Duration TIME_BETWEEN_CHECKS = Duration.ofDays(1);\n\n\t/**\n\t * Seconds around the check to avoid tracking.\n\t */\n\tprivate static final long SKEW_SECONDS = 240;\n\n\tprivate final Preferences preferences;\n\n\tpublic VersionChecker()\n\t{\n\t\tpreferences = PreferenceUtils.getPreferences().node(UPDATE_CHECK);\n\t}\n\n\tpublic boolean isVersionMoreRecent(String newVersionString, String currentVersionString, boolean allowSkip)\n\t{\n\t\tif (StringUtils.isBlank(newVersionString))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\tvar currentVersion = createVersion(currentVersionString);\n\t\tif (currentVersion.isNotARelease())\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\tvar newVersion = createVersion(newVersionString);\n\n\t\tvar versionToSkip = createVersion(preferences.get(KEY_SKIP, \"0.0.0\"));\n\t\tif (newVersion.equals(versionToSkip))\n\t\t{\n\t\t\tif (allowSkip)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\t// This was a manual check, so the user is interested in getting an update,\n\t\t\t// unskip the current version if any.\n\t\t\tpreferences.remove(KEY_SKIP);\n\t\t}\n\t\treturn newVersion.compareTo(currentVersion) > 0;\n\t}\n\n\tprivate static Version createVersion(String versionString)\n\t{\n\t\tif (!versionString.startsWith(\"v\"))\n\t\t{\n\t\t\tversionString = \"v\" + versionString;\n\t\t}\n\t\tvar matcher = VERSION_PATTERN.matcher(versionString);\n\n\t\tif (matcher.matches())\n\t\t{\n\t\t\treturn new Version((Integer.parseInt(matcher.group(1))), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)));\n\t\t}\n\t\treturn new Version(0, 0, 0);\n\t}\n\n\tpublic void scheduleVersionCheck(Runnable check)\n\t{\n\t\tvar timer = new Timer(\"Version Update Checker\", true);\n\n\t\tRunnable task = () -> {\n\t\t\tif (shouldCheckForUpdate(preferences))\n\t\t\t{\n\t\t\t\tcheck.run();\n\t\t\t}\n\t\t};\n\n\t\tvar versionCheckTask = new VersionCheckTask(task);\n\t\tvar skew = ThreadLocalRandom.current().nextLong(SKEW_SECONDS) - SKEW_SECONDS / 2;\n\t\ttimer.scheduleAtFixedRate(versionCheckTask, 0L, Duration.ofDays(1).plus(Duration.ofSeconds(skew)).toMillis());\n\t}\n\n\tpublic void skipUpdate(String versionString)\n\t{\n\t\tvar versionToSkip = createVersion(versionString);\n\t\tif (versionToSkip.isNotARelease())\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tpreferences.put(KEY_SKIP, versionToSkip.toString());\n\t}\n\n\tprivate static boolean shouldCheckForUpdate(Preferences preferences)\n\t{\n\t\tvar now = Instant.now();\n\t\tvar shouldCheck = preferences.getBoolean(KEY_ENABLED, false) && Duration.between(Instant.ofEpochMilli(preferences.getLong(KEY_LAST_CHECK, 0)), now).abs().compareTo(TIME_BETWEEN_CHECKS.minus(Duration.ofSeconds(SKEW_SECONDS))) > 0;\n\t\tif (shouldCheck)\n\t\t{\n\t\t\tpreferences.putLong(KEY_LAST_CHECK, now.toEpochMilli());\n\t\t}\n\t\treturn shouldCheck;\n\t}\n\n\tpublic static boolean isCheckForUpdates(Preferences preferences)\n\t{\n\t\treturn preferences.getBoolean(KEY_ENABLED, false);\n\t}\n\n\tpublic static void setCheckForUpdates(Preferences preferences, boolean check)\n\t{\n\t\tpreferences.putBoolean(KEY_ENABLED, check);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/AbstractUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.Locale;\n\npublic abstract class AbstractUriFactory\n{\n\tprotected static final String PROTOCOL_RETROSHARE = \"retroshare\";\n\n\tpublic abstract String getAuthority();\n\n\t/**\n\t * Creates a content object for a URL\n\t *\n\t * @param uriComponents the uri components, not null\n\t * @param text          the text to display in the content object\n\t * @param uriAction     the uri action to perform when clicking on the object, not null\n\t * @return the content object, never null\n\t */\n\tabstract Content createContent(UriComponents uriComponents, String text, UriAction uriAction);\n\n\tabstract Uri createUri(UriComponents uriComponents);\n\n\tpublic String getProtocol()\n\t{\n\t\treturn PROTOCOL_RETROSHARE;\n\t}\n\n\tprotected static long getLongHexArgument(String s)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Long.parseUnsignedLong(s.toLowerCase(Locale.ROOT), 16);\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\treturn 0L;\n\t\t}\n\t}\n\n\tprotected static long getLongArgument(String s)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Long.parseUnsignedLong(s);\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\treturn 0L;\n\t\t}\n\t}\n\n\tprotected static int getIntArgument(String s)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Integer.parseInt(s);\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\tprotected static Sha1Sum getHashArgument(String s)\n\t{\n\t\treturn Sha1Sum.fromString(s);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/BoardUri.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.MsgId;\n\npublic record BoardUri(String name, GxsId gxsId, MsgId msgId) implements Uri\n{\n\tstatic final String AUTHORITY = \"posted\";\n\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_GXS_ID = \"id\";\n\tstatic final String PARAMETER_MSG_ID = \"msgid\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_GXS_ID, Id.toString(gxsId),\n\t\t\t\tPARAMETER_MSG_ID, Id.toString(msgId));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/BoardUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.BoardUri.*;\n\npublic class BoardUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar boardUri = createUri(uriComponents);\n\t\tif (boardUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(boardUri, StringUtils.isNotBlank(text) ? text : boardUri.name(), uriAction::openUri);\n\t}\n\n\t@Override\n\tBoardUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar boardId = uriComponents.getQueryParams().getFirst(PARAMETER_GXS_ID);\n\t\tvar msgId = uriComponents.getQueryParams().getFirst(PARAMETER_MSG_ID);\n\n\t\tif (Stream.of(name, boardId).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new BoardUri(name, GxsId.fromString(boardId), StringUtils.isNotBlank(msgId) ? MsgId.fromString(msgId) : null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/CertificateUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\npublic record CertificateUri(String radix, String name, String location) implements Uri\n{\n\tstatic final String AUTHORITY = \"certificate\";\n\tstatic final String PARAMETER_RADIX = \"radix\";\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_LOCATION = \"location\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_RADIX, radix,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_LOCATION, location);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/CertificateUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.AppName;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport static io.xeres.ui.support.uri.CertificateUri.*;\n\npublic class CertificateUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar certificateUri = createUri(uriComponents);\n\t\tif (certificateUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\t\treturn new ContentUri(certificateUri, StringUtils.isNotBlank(text) ? text : generateName(certificateUri.name(), certificateUri.location()), uriAction::openUri);\n\t}\n\n\t@Override\n\tCertificateUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar radix = uriComponents.getQueryParams().getFirst(PARAMETER_RADIX);\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar location = uriComponents.getQueryParams().getFirst(PARAMETER_LOCATION);\n\n\t\tif (StringUtils.isBlank(radix))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new CertificateUri(radix, name, location);\n\t}\n\n\tprivate static String generateName(String name, String location)\n\t{\n\t\tvar sb = new StringBuilder(AppName.NAME);\n\t\tsb.append(\" Certificate (\");\n\n\t\tif (StringUtils.isNotBlank(name))\n\t\t{\n\t\t\tsb.append(name);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsb.append(\"unknown\");\n\t\t}\n\t\tif (StringUtils.isNotBlank(location))\n\t\t{\n\t\t\tsb.append(\", @\");\n\t\t\tsb.append(location);\n\t\t}\n\t\tsb.append(\")\");\n\t\treturn sb.toString();\n\t}\n\n\t/**\n\t * Generates the certificate in a friendly way, since otherwise this would generate big URLs.\n\t *\n\t * @param radix    the encoded certificate in base64\n\t * @param name     the name\n\t * @param location the location\n\t * @return a link URL\n\t */\n\tpublic static String generate(String radix, String name, String location)\n\t{\n\t\tvar certificateUri = new CertificateUri(radix, name, location);\n\t\treturn \"<a href=\\\"\" + certificateUri.toUriString() + \"\\\">\" + AppName.NAME + \" Certificate (\" + name + \", @\" + location + \")</a>\";\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ChannelUri.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.MsgId;\n\npublic record ChannelUri(String name, GxsId gxsId, MsgId msgId) implements Uri\n{\n\tstatic final String AUTHORITY = \"channel\";\n\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_GXS_ID = \"id\";\n\tstatic final String PARAMETER_MSG_ID = \"msgid\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_GXS_ID, Id.toString(gxsId),\n\t\t\t\tPARAMETER_MSG_ID, Id.toString(msgId));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ChannelUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.ChannelUri.*;\n\npublic class ChannelUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar channelUri = createUri(uriComponents);\n\t\tif (channelUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(channelUri, StringUtils.isNotBlank(text) ? text : channelUri.name(), uriAction::openUri);\n\t}\n\n\t@Override\n\tChannelUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar id = uriComponents.getQueryParams().getFirst(PARAMETER_GXS_ID);\n\t\tvar msgId = uriComponents.getQueryParams().getFirst(PARAMETER_MSG_ID);\n\n\t\tif (Stream.of(name, id).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChannelUri(name, GxsId.fromString(id), StringUtils.isNotBlank(msgId) ? MsgId.fromString(msgId) : null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ChatRoomUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Id;\n\npublic record ChatRoomUri(String name, long id) implements Uri\n{\n\tstatic final String AUTHORITY = \"chat_room\";\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_ID = \"id\";\n\tstatic final String CHAT_ROOM_PREFIX = \"L\";\n\tstatic String PRIVATE_MESSAGE_PREFIX = \"P\";\n\tstatic String DISTANT_CHAT_PREFIX = \"D\";\n\tstatic String BROADCAST_PREFIX = \"L\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_ID, CHAT_ROOM_PREFIX + Id.toString(id));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ChatRoomUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.ChatRoomUri.*;\n\npublic class ChatRoomUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar chatRoomUri = createUri(uriComponents);\n\n\t\tif (chatRoomUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(chatRoomUri, StringUtils.isNotBlank(text) ? text : chatRoomUri.name(), uriAction::openUri);\n\t}\n\n\t@Override\n\tChatRoomUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar id = uriComponents.getQueryParams().getFirst(PARAMETER_ID);\n\n\t\tif (Stream.of(name, id).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ChatRoomUri(name, id.length() > 1 && id.startsWith(CHAT_ROOM_PREFIX) ? getLongHexArgument(id.substring(1)) : 0);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/CollectionUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\npublic record CollectionUri(String name, long size, String radix, int count) implements Uri\n{\n\tstatic final String AUTHORITY = \"collection\";\n\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_SIZE = \"size\";\n\tstatic final String PARAMETER_RADIX = \"radix\";\n\tstatic final String PARAMETER_FILES = \"files\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_SIZE, String.valueOf(size),\n\t\t\t\tPARAMETER_RADIX, radix,\n\t\t\t\tPARAMETER_FILES, String.valueOf(count));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/CollectionUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.CollectionUri.*;\n\npublic class CollectionUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar collectionUri = createUri(uriComponents);\n\t\tif (collectionUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\t//noinspection ConstantConditions\n\t\treturn new ContentUri(collectionUri, StringUtils.isNotBlank(text) ? text : (collectionUri.name() + \" (\" + collectionUri.count() + \"files, \" + ByteUnitUtils.fromBytes(collectionUri.size()) + \")\"), uriAction::openUri);\n\t}\n\n\t@Override\n\tCollectionUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar size = uriComponents.getQueryParams().getFirst(PARAMETER_SIZE);\n\t\tvar radix = uriComponents.getQueryParams().getFirst(PARAMETER_RADIX);\n\t\tvar count = uriComponents.getQueryParams().getFirst(PARAMETER_FILES);\n\n\t\tif (Stream.of(name, size, radix, count).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new CollectionUri(name, getLongArgument(size), radix, getIntArgument(count));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ExternalUri.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\npublic record ExternalUri(String uri) implements Uri\n{\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn uri;\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ExternalUriFactory.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.net.URLDecoder;\nimport java.nio.charset.StandardCharsets;\n\npublic class ExternalUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn null;\n\t}\n\n\t@Override\n\tContent createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar externalUri = createUri(uriComponents);\n\n\t\treturn new ContentUri(externalUri, StringUtils.isNotBlank(text) ? text : externalUri.toUriString(), uriAction::openUri);\n\t}\n\n\t@Override\n\tExternalUri createUri(UriComponents uriComponents)\n\t{\n\t\treturn new ExternalUri(URLDecoder.decode(uriComponents.toUriString(), StandardCharsets.UTF_8));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/FileUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Sha1Sum;\n\npublic record FileUri(String name, long size, Sha1Sum hash) implements Uri\n{\n\tstatic final String AUTHORITY = \"file\";\n\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_SIZE = \"size\";\n\tstatic final String PARAMETER_HASH = \"hash\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_SIZE, String.valueOf(size),\n\t\t\t\tPARAMETER_HASH, hash.toString());\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/FileUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.FileUri.*;\n\npublic class FileUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar fileUri = createUri(uriComponents);\n\t\tif (fileUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(fileUri, StringUtils.isNotBlank(text) ? text : (fileUri.name() + \" (\" + ByteUnitUtils.fromBytes(fileUri.size()) + \")\"), uriAction::openUri);\n\t}\n\n\t@Override\n\tFileUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar size = uriComponents.getQueryParams().getFirst(PARAMETER_SIZE);\n\t\tvar hash = uriComponents.getQueryParams().getFirst(PARAMETER_HASH);\n\n\t\tif (Stream.of(name, size, hash).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new FileUri(name, getLongArgument(size), getHashArgument(hash));\n\t}\n\n\tpublic static String generate(String name, long size, Sha1Sum hash)\n\t{\n\t\tvar fileUri = new FileUri(name, size, hash);\n\n\t\treturn \"<a href=\\\"\" + fileUri.toUriString() + \"\\\">\" + name + \" (\" + ByteUnitUtils.fromBytes(size) + \")\" + \"</a>\";\n\t}\n\n\tpublic static String generateMarkdown(String name, long size, Sha1Sum hash)\n\t{\n\t\tvar fileUri = new FileUri(name, size, hash);\n\n\t\treturn \"[\" + fileUri.name() + \"](\" + fileUri.toUriString() + \") (\" + ByteUnitUtils.fromBytes(size) + \")\";\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ForumUri.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.MsgId;\n\npublic record ForumUri(String name, GxsId gxsId, MsgId msgId) implements Uri\n{\n\tstatic final String AUTHORITY = \"forum\";\n\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_GXS_ID = \"id\";\n\tstatic final String PARAMETER_MSG_ID = \"msgid\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_GXS_ID, Id.toString(gxsId),\n\t\t\t\tPARAMETER_MSG_ID, Id.toString(msgId));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ForumUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.ForumUri.*;\n\npublic class ForumUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar forumUri = createUri(uriComponents);\n\t\tif (forumUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(forumUri, StringUtils.isNotBlank(text) ? text : forumUri.name(), uriAction::openUri);\n\t}\n\n\t@Override\n\tForumUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar id = uriComponents.getQueryParams().getFirst(PARAMETER_GXS_ID);\n\t\tvar msgId = uriComponents.getQueryParams().getFirst(PARAMETER_MSG_ID);\n\n\t\tif (Stream.of(name, id).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ForumUri(name, GxsId.fromString(id), StringUtils.isNotBlank(msgId) ? MsgId.fromString(msgId) : null);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/IdentityUri.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Id;\n\npublic record IdentityUri(String name, GxsId gxsId, String groupData) implements Uri\n{\n\tstatic final String AUTHORITY = \"identity\";\n\n\tstatic final String PARAMETER_GXS_ID = \"gxsid\";\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_GROUPDATA = \"groupdata\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_GXS_ID, Id.toString(gxsId),\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_GROUPDATA, groupData);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/IdentityUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.IdentityUri.*;\n\npublic class IdentityUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar identityUri = createUri(uriComponents);\n\t\tif (identityUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(identityUri, StringUtils.isNotBlank(text) ? text : (\"Identity (name=\" + identityUri.name() + \", ID=\" + identityUri.gxsId() + \")\"), uriAction::openUri);\n\t}\n\n\t@Override\n\tIdentityUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar gxsId = uriComponents.getQueryParams().getFirst(PARAMETER_GXS_ID);\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar groupData = uriComponents.getQueryParams().getFirst(PARAMETER_GROUPDATA);\n\n\t\tif (Stream.of(gxsId, name).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new IdentityUri(name, GxsId.fromString(gxsId), groupData); // groupData contains the gxs group's data so that the peer can do something with it even if it doesn't have the group yet\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/MessageUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Id;\nimport io.xeres.common.id.Identifier;\n\npublic record MessageUri(Identifier identifier, String subject) implements Uri\n{\n\tstatic final String AUTHORITY = \"message\";\n\n\tstatic final String PARAMETER_ID = \"id\";\n\tstatic final String PARAMETER_SUBJECT = \"subject\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_ID, Id.toString(identifier),\n\t\t\t\tPARAMETER_SUBJECT, subject);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/MessageUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.GxsId;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport static io.xeres.ui.support.uri.MessageUri.*;\nimport static org.apache.commons.lang3.StringUtils.isBlank;\n\npublic class MessageUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar messageUri = createUri(uriComponents);\n\t\tif (messageUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(messageUri, StringUtils.isNotBlank(text) ? text : messageUri.identifier().toString(), uriAction::openUri);\n\t}\n\n\t@Override\n\tMessageUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar id = uriComponents.getQueryParams().getFirst(PARAMETER_ID); // XXX: warning: it can be of different type (gxsId, location identifier, etc...). We need to detect it first\n\t\tvar subject = uriComponents.getQueryParams().getFirst(PARAMETER_SUBJECT);\n\n\t\tif (isBlank(id))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new MessageUri(GxsId.fromString(id), subject);\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ProfileUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Id;\n\npublic record ProfileUri(String name, long hash) implements Uri\n{\n\tstatic final String AUTHORITY = \"person\";\n\n\tstatic final String PARAMETER_NAME = \"name\";\n\tstatic final String PARAMETER_HASH = \"hash\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_NAME, name,\n\t\t\t\tPARAMETER_HASH, Id.toString(hash));\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/ProfileUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.common.id.Id;\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport java.util.stream.Stream;\n\nimport static io.xeres.ui.support.uri.ProfileUri.*;\n\npublic class ProfileUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar profileUri = createUri(uriComponents);\n\t\tif (profileUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(profileUri, StringUtils.isNotBlank(text) ? text : (profileUri.name() + \"@\" + Id.toString(profileUri.hash())), uriAction::openUri);\n\t}\n\n\t@Override\n\tProfileUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar name = uriComponents.getQueryParams().getFirst(PARAMETER_NAME);\n\t\tvar hash = uriComponents.getQueryParams().getFirst(PARAMETER_HASH);\n\n\t\tif (Stream.of(name, hash).anyMatch(StringUtils::isBlank))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new ProfileUri(name, getLongHexArgument(hash));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/SearchUri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\npublic record SearchUri(String keywords) implements Uri\n{\n\tstatic final String AUTHORITY = \"search\";\n\n\tstatic final String PARAMETER_KEYWORDS = \"keywords\";\n\n\t@Override\n\tpublic String toUriString()\n\t{\n\t\treturn Uri.buildUri(AUTHORITY,\n\t\t\t\tPARAMETER_KEYWORDS, keywords);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn toUriString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/SearchUriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriComponents;\n\nimport static io.xeres.ui.support.uri.SearchUri.AUTHORITY;\nimport static io.xeres.ui.support.uri.SearchUri.PARAMETER_KEYWORDS;\n\npublic class SearchUriFactory extends AbstractUriFactory\n{\n\t@Override\n\tpublic String getAuthority()\n\t{\n\t\treturn AUTHORITY;\n\t}\n\n\t@Override\n\tpublic Content createContent(UriComponents uriComponents, String text, UriAction uriAction)\n\t{\n\t\tvar searchUri = createUri(uriComponents);\n\t\tif (searchUri == null)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\n\t\treturn new ContentUri(searchUri, StringUtils.isNotBlank(text) ? text : searchUri.keywords(), uriAction::openUri);\n\t}\n\n\t@Override\n\tSearchUri createUri(UriComponents uriComponents)\n\t{\n\t\tvar keywords = uriComponents.getQueryParams().getFirst(PARAMETER_KEYWORDS);\n\n\t\tif (StringUtils.isBlank(keywords))\n\t\t{\n\t\t\treturn null;\n\t\t}\n\n\t\treturn new SearchUri(keywords.trim());\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/Uri.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.UriUtils;\n\nimport static java.nio.charset.StandardCharsets.UTF_8;\n\npublic sealed interface Uri permits BoardUri, CertificateUri, ChannelUri, ChatRoomUri, CollectionUri, ExternalUri, FileUri, ForumUri, IdentityUri, MessageUri, ProfileUri, SearchUri\n{\n\tString toUriString();\n\n\tstatic String buildUri(String authority, String... args)\n\t{\n\t\tvar sb = new StringBuilder(\"retroshare\");\n\t\tvar firstArg = true;\n\n\t\tif (args.length % 2 != 0)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Wrong number of arguments: must be name and value pairs\");\n\t\t}\n\t\tsb.append(\"://\");\n\t\tsb.append(authority);\n\n\t\tfor (var i = 0; i < args.length; i += 2)\n\t\t{\n\t\t\tif (StringUtils.isNotBlank(args[i + 1]))\n\t\t\t{\n\t\t\t\tif (firstArg)\n\t\t\t\t{\n\t\t\t\t\tsb.append(\"?\");\n\t\t\t\t\tfirstArg = false;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tsb.append(\"&\");\n\t\t\t\t}\n\t\t\t\tsb.append(args[i]);\n\t\t\t\tsb.append(\"=\");\n\t\t\t\tsb.append(UriUtils.encodeQueryParam(args[i + 1], UTF_8));\n\t\t\t}\n\t\t}\n\t\treturn sb.toString();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/UriFactory.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.ui.support.contentline.Content;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.springframework.web.util.UriComponentsBuilder;\n\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Objects;\n\nimport static org.apache.commons.lang3.StringUtils.isBlank;\n\npublic final class UriFactory\n{\n\tprivate static final Map<String, Map<String, AbstractUriFactory>> contentParsers = new HashMap<>();\n\n\tstatic\n\t{\n\t\taddContentParser(new BoardUriFactory());\n\t\taddContentParser(new CertificateUriFactory());\n\t\taddContentParser(new ChannelUriFactory());\n\t\taddContentParser(new ChatRoomUriFactory());\n\t\taddContentParser(new FileUriFactory());\n\t\taddContentParser(new ForumUriFactory());\n\t\taddContentParser(new IdentityUriFactory());\n\t\taddContentParser(new MessageUriFactory());\n\t\taddContentParser(new ProfileUriFactory());\n\t\taddContentParser(new SearchUriFactory());\n\t\taddContentParser(new CollectionUriFactory());\n\t}\n\n\tprivate static final ExternalUriFactory externalUriFactory = new ExternalUriFactory();\n\n\tprivate UriFactory()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static void addContentParser(AbstractUriFactory contentParser)\n\t{\n\t\tvar map = contentParsers.getOrDefault(contentParser.getProtocol(), new HashMap<>());\n\t\tvar authority = contentParser.getAuthority();\n\t\tObjects.requireNonNull(authority, \"Authority cannot be null for \" + contentParser.getClass().getSimpleName() + \", or it's not supposed to be used as a content parser\");\n\t\tmap.put(authority, contentParser);\n\t\tcontentParsers.put(contentParser.getProtocol(), map);\n\t}\n\n\tpublic static Uri createUri(String href)\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar uri = new URI(href);\n\t\t\tvar contentParserMap = contentParsers.get(uri.getScheme());\n\t\t\tif (contentParserMap != null)\n\t\t\t{\n\t\t\t\tvar contentParser = contentParserMap.get(uri.getAuthority());\n\n\t\t\t\tif (contentParser != null)\n\t\t\t\t{\n\t\t\t\t\tvar uriComponents = UriComponentsBuilder.fromPath(uri.getPath())\n\t\t\t\t\t\t\t.query(uri.getQuery())\n\t\t\t\t\t\t\t.build();\n\n\t\t\t\t\treturn contentParser.createUri(uriComponents);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn externalUriFactory.createUri(UriComponentsBuilder.fromUri(uri).build());\n\t\t}\n\t\tcatch (URISyntaxException _)\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tpublic static Content createContent(String href, String text, UriAction uriAction)\n\t{\n\t\tif (isBlank(href))\n\t\t{\n\t\t\treturn new ContentText(text);\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tvar uri = new URI(href);\n\t\t\tvar contentParserMap = contentParsers.get(uri.getScheme());\n\t\t\tif (contentParserMap != null)\n\t\t\t{\n\t\t\t\tvar contentParser = contentParserMap.get(uri.getAuthority());\n\n\t\t\t\tif (contentParser != null)\n\t\t\t\t{\n\t\t\t\t\tvar uriComponents = UriComponentsBuilder.fromPath(uri.getPath())\n\t\t\t\t\t\t\t.query(uri.getQuery())\n\t\t\t\t\t\t\t.build();\n\n\t\t\t\t\treturn contentParser.createContent(uriComponents, text, uriAction);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn externalUriFactory.createContent(UriComponentsBuilder.fromUri(uri).build(), text, uriAction);\n\t\t}\n\t\tcatch (URISyntaxException _)\n\t\t{\n\t\t\treturn new ContentText(\"\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/uri/UriService.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.support.markdown.UriAction;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\n\n/**\n * This service is responsible for opening URIs within the application.\n */\n@Service\npublic class UriService implements UriAction\n{\n\tprivate final ApplicationEventPublisher eventPublisher;\n\n\tpublic UriService(ApplicationEventPublisher eventPublisher)\n\t{\n\t\tthis.eventPublisher = eventPublisher;\n\t}\n\n\t/**\n\t * Opens a URI to show within the application.\n\t *\n\t * @param uri the URI to open.\n\t */\n\t@Override\n\tpublic void openUri(Uri uri)\n\t{\n\t\teventPublisher.publishEvent(new OpenUriEvent(uri));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/ChooserUtils.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport javafx.stage.DirectoryChooser;\nimport javafx.stage.FileChooser;\n\nimport java.io.File;\nimport java.nio.file.Path;\n\n/**\n * Utility class that prevents a FileChooser or DirectoryChooser from failing to show up if the\n * initial directory doesn't exist or is not a directory.\n */\npublic final class ChooserUtils\n{\n\tprivate ChooserUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void setInitialDirectory(DirectoryChooser chooser, String initialDirectory)\n\t{\n\t\tif (initialDirectory == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsetInitialDirectory(chooser, Path.of(initialDirectory));\n\t}\n\n\tpublic static void setInitialDirectory(DirectoryChooser chooser, Path initialDirectory)\n\t{\n\t\tif (initialDirectory == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsetInitialDirectory(chooser, initialDirectory.toFile());\n\t}\n\n\tpublic static void setInitialDirectory(DirectoryChooser chooser, File initialDirectory)\n\t{\n\t\tif (initialDirectory == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tif (initialDirectory.isDirectory())\n\t\t{\n\t\t\tchooser.setInitialDirectory(initialDirectory);\n\t\t}\n\t}\n\n\tpublic static void setInitialDirectory(FileChooser chooser, String initialDirectory)\n\t{\n\t\tif (initialDirectory == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsetInitialDirectory(chooser, Path.of(initialDirectory));\n\t}\n\n\tpublic static void setInitialDirectory(FileChooser chooser, Path initialDirectory)\n\t{\n\t\tif (initialDirectory == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tsetInitialDirectory(chooser, initialDirectory.toFile());\n\t}\n\n\tpublic static void setInitialDirectory(FileChooser chooser, File initialDirectory)\n\t{\n\t\tif (initialDirectory == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tif (initialDirectory.isDirectory())\n\t\t{\n\t\t\tchooser.setInitialDirectory(initialDirectory);\n\t\t}\n\t}\n\n\tpublic static void setSupportedLoadImageFormats(FileChooser chooser)\n\t{\n\t\tchooser.getExtensionFilters().addAll(new FileChooser.ExtensionFilter(I18nUtils.getBundle().getString(\"file-requester.images\"), \"*.png\", \"*.jpg\", \"*.jpeg\", \"*.jfif\", \"*.webp\", \"*.gif\", \"*.bmp\", \"*.ico\", \"*.iff\", \"*.svg\"));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/ClientUtils.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.core.io.FileSystemResource;\nimport org.springframework.http.HttpEntity;\nimport org.springframework.http.client.MultipartBodyBuilder;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.reactive.function.client.ClientResponse;\nimport org.springframework.web.util.UriComponentsBuilder;\nimport reactor.core.publisher.Mono;\n\nimport java.io.File;\n\npublic final class ClientUtils\n{\n\tprivate ClientUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * User agent that should be used for external calls to avoid tracking.\n\t * Check <a href=https://microlink.io/user-agents>here</a> from time to time.\n\t */\n\tpublic static final String GENERAL_USER_AGENT = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36\";\n\n\tpublic static MultiValueMap<String, HttpEntity<?>> fromFile(File file)\n\t{\n\t\tvar builder = new MultipartBodyBuilder();\n\t\tbuilder.part(\"file\", new FileSystemResource(file));\n\t\treturn builder.build();\n\t}\n\n\t/**\n\t * Gets the ID from a client's POST creation request by using the Location: header.\n\t *\n\t * @param response the response of the WebClient\n\t * @return the ID\n\t */\n\tpublic static Mono<Long> getCreatedId(ClientResponse response)\n\t{\n\t\tif (response.statusCode().is2xxSuccessful())\n\t\t{\n\t\t\tvar location = response.headers().asHttpHeaders().getLocation();\n\n\t\t\tif (location != null)\n\t\t\t{\n\t\t\t\tvar uriComponents = UriComponentsBuilder.fromUri(location).build();\n\t\t\t\tString lastPathSegment = uriComponents.getPathSegments().getLast();\n\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\treturn Mono.just(Long.parseLong(lastPathSegment));\n\t\t\t\t}\n\t\t\t\tcatch (NumberFormatException e)\n\t\t\t\t{\n\t\t\t\t\treturn Mono.error(new IllegalArgumentException(\"Failed to parse ID from location header: \" + location, e));\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn Mono.error(new IllegalArgumentException(\"Location header not found in response\"));\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn response.createError();\n\t\t}\n\t}\n\n\tpublic static MultipartBodyBuilder createGroupBuilder(String name, String description, File image)\n\t{\n\t\tvar builder = new MultipartBodyBuilder();\n\t\tif (StringUtils.isBlank(name))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Name is required\");\n\t\t}\n\t\tbuilder.part(\"name\", name);\n\t\tif (StringUtils.isBlank(description))\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Description is required\");\n\t\t}\n\t\tbuilder.part(\"description\", description);\n\t\tif (image != null)\n\t\t{\n\t\t\tbuilder.part(\"image\", new FileSystemResource(image));\n\t\t}\n\t\treturn builder;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/DateUtils.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.time.format.DateTimeFormatter;\n\n/**\n * Utility class where all time and date displays are located. It only supports\n * ISO style.\n */\npublic final class DateUtils\n{\n\t/**\n\t * Formats the date and time, like: 2026-01-06 21:39\n\t */\n\tpublic static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\t/**\n\t * Formats the date and time with seconds, like: 2026-01-06 21:40:36\n\t */\n\tpublic static final DateTimeFormatter DATE_TIME_PRECISE_FORMAT = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\t/**\n\t * Formats the time in a localized way, like: 21:37\n\t */\n\tpublic static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern(\"HH:mm\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\t/**\n\t * Formats the date only, like: 2026-01-06\n\t */\n\tpublic static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern(\"yyyy-MM-dd\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\t/**\n\t * Formats the time with seconds in a localized way, like: 21:41:38\n\t */\n\tpublic static final DateTimeFormatter TIME_PRECISE_FORMAT = DateTimeFormatter.ofPattern(\"HH:mm:ss\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\t/**\n\t * Formats the date and time, to be used as a filename, like: 2026-01-06_214229\n\t */\n\tpublic static final DateTimeFormatter DATE_TIME_FILENAME_FORMAT = DateTimeFormatter.ofPattern(\"yyyy-MM-dd_HHmmss\")\n\t\t\t.withZone(ZoneId.systemDefault());\n\n\tprivate DateUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Formats a date and time with a default string.\n\t *\n\t * @param instant the instant, if null or EPOCH, then the default string is returned instead\n\t * @param unset   the default string\n\t * @return the string\n\t */\n\tpublic static String formatDateTime(Instant instant, String unset)\n\t{\n\t\tif (instant != null && instant.isAfter(Instant.EPOCH))\n\t\t{\n\t\t\treturn DATE_TIME_FORMAT.format(instant);\n\t\t}\n\t\telse\n\t\t{\n\t\t\treturn unset;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/ImageViewUtils.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.OsUtils;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport javafx.embed.swing.SwingFXUtils;\nimport javafx.event.ActionEvent;\nimport javafx.geometry.Dimension2D;\nimport javafx.geometry.Pos;\nimport javafx.geometry.Rectangle2D;\nimport javafx.scene.Node;\nimport javafx.scene.Scene;\nimport javafx.scene.SnapshotParameters;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.control.SeparatorMenuItem;\nimport javafx.scene.image.Image;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.MouseButton;\nimport javafx.scene.input.MouseEvent;\nimport javafx.scene.layout.HBox;\nimport javafx.scene.layout.Priority;\nimport javafx.scene.layout.VBox;\nimport javafx.scene.paint.Color;\nimport javafx.stage.FileChooser;\nimport javafx.stage.Screen;\nimport javafx.stage.Stage;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignC;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignI;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.imageio.ImageIO;\nimport java.io.IOException;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\n\nimport static io.xeres.ui.support.preference.PreferenceUtils.IMAGE_VIEW;\nimport static io.xeres.ui.support.util.DateUtils.DATE_TIME_FILENAME_FORMAT;\nimport static io.xeres.ui.support.util.UiUtils.getWindow;\nimport static javafx.scene.control.Alert.AlertType.ERROR;\n\npublic final class ImageViewUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(ImageViewUtils.class);\n\n\tprivate ImageViewUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static final ContextMenu contextMenu;\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\tprivate static final String FULL_SCREEN_HINT_DISPLAYED = \"FullScreenHintDisplayed\";\n\n\tstatic\n\t{\n\t\tvar viewMenuItem = new MenuItem(bundle.getString(\"view-fullscreen\"));\n\t\tviewMenuItem.setGraphic(new FontIcon(MaterialDesignI.IMAGE));\n\t\tviewMenuItem.setOnAction(ImageViewUtils::view);\n\n\t\tvar copyMenuItem = new MenuItem(bundle.getString(\"copy-image\"));\n\t\tcopyMenuItem.setGraphic(new FontIcon(MaterialDesignC.CONTENT_COPY));\n\t\tcopyMenuItem.setOnAction(ImageViewUtils::copyToClipboard);\n\n\t\tvar saveAsMenuItem = new MenuItem(bundle.getString(\"save-image-as\"));\n\t\tsaveAsMenuItem.setGraphic(new FontIcon(MaterialDesignC.CONTENT_SAVE));\n\t\tsaveAsMenuItem.setOnAction(ImageViewUtils::saveAs);\n\n\t\tcontextMenu = new ContextMenu(viewMenuItem, new SeparatorMenuItem(), copyMenuItem, saveAsMenuItem);\n\t}\n\n\t/**\n\t * Limits the size of an image by scaling it down. The aspect ratio is always preserved.\n\t *\n\t * @param imageView     the image to modify\n\t * @param maximumWidth  the maximum width of the image\n\t * @param maximumHeight the maximum height of the image\n\t */\n\tpublic static void limitMaximumImageSize(ImageView imageView, int maximumWidth, int maximumHeight)\n\t{\n\t\tvar width = imageView.getImage().getWidth();\n\t\tvar height = imageView.getImage().getHeight();\n\n\t\tif (width > maximumWidth || height > maximumHeight)\n\t\t{\n\t\t\tvar scaleImageView = new ImageView(imageView.getImage());\n\t\t\tscaleImageView.setPreserveRatio(true);\n\t\t\tscaleImageView.setSmooth(true);\n\t\t\tif (width > height)\n\t\t\t{\n\t\t\t\tscaleImageView.setFitWidth(maximumWidth);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tscaleImageView.setFitHeight(maximumHeight);\n\t\t\t}\n\t\t\tvar parameters = new SnapshotParameters();\n\t\t\tparameters.setFill(Color.TRANSPARENT); // Make sure we don't break PNGs\n\t\t\tif (imageView instanceof AsyncImageView asyncImageView)\n\t\t\t{\n\t\t\t\tasyncImageView.updateImage(scaleImageView.snapshot(parameters, null));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\timageView.setImage(scaleImageView.snapshot(parameters, null));\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Limits the size of an image by scaling it down. The aspect ratio is always preserved.\n\t *\n\t * @param imageView   the image to modify\n\t * @param maximumSize the maximum size of the image in total number of pixels\n\t */\n\tpublic static void limitMaximumImageSize(ImageView imageView, int maximumSize)\n\t{\n\t\tvar width = imageView.getImage().getWidth();\n\t\tvar height = imageView.getImage().getHeight();\n\n\t\tvar actualSize = width * height;\n\n\t\tif (actualSize > maximumSize)\n\t\t{\n\t\t\tvar ratio = Math.sqrt(maximumSize / actualSize);\n\t\t\tvar scaleImageView = new ImageView(imageView.getImage());\n\t\t\tscaleImageView.setFitWidth(width * ratio);\n\t\t\tscaleImageView.setFitHeight(height * ratio);\n\t\t\tscaleImageView.setSmooth(true);\n\n\t\t\tvar parameters = new SnapshotParameters();\n\t\t\tparameters.setFill(Color.TRANSPARENT); // Make sure we don't break PNGs\n\t\t\tif (imageView instanceof AsyncImageView asyncImageView)\n\t\t\t{\n\t\t\t\tasyncImageView.updateImage(scaleImageView.snapshot(parameters, null));\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\timageView.setImage(scaleImageView.snapshot(parameters, null));\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Limits the size of an image by scaling it down. The aspect ratio is always preserved.\n\t *\n\t * @param width         the image width\n\t * @param height        the image height\n\t * @param maximumWidth  the width constraint\n\t * @param maximumHeight the height constraint\n\t * @return a dimension that doesn't exceed the maximum width nor the maximum height\n\t */\n\tpublic static Dimension2D limitMaximumImageSize(double width, double height, int maximumWidth, int maximumHeight)\n\t{\n\t\tvar ratio = width / height;\n\t\tif (width > maximumWidth)\n\t\t{\n\t\t\twidth = maximumWidth;\n\t\t\theight = width / ratio;\n\t\t}\n\t\tif (height > maximumHeight)\n\t\t{\n\t\t\theight = maximumHeight;\n\t\t\twidth = ratio * height;\n\t\t}\n\t\treturn new Dimension2D(width, height);\n\t}\n\n\t/**\n\t * Checks if an image has an exaggerated aspect ratio, that is, excessive horizontal\n\t * or vertical length to try to mess up the UI.\n\t *\n\t * @param image the image to check\n\t * @return true if the aspect ratio is excessive\n\t */\n\tpublic static boolean isExaggeratedAspectRatio(Image image)\n\t{\n\t\tvar width = image.getWidth();\n\t\tvar height = image.getHeight();\n\n\t\tdouble aspectRatio;\n\n\t\tif (width > height)\n\t\t{\n\t\t\taspectRatio = height / width;\n\t\t}\n\t\telse\n\t\t{\n\t\t\taspectRatio = width / height;\n\t\t}\n\t\treturn aspectRatio < 0.0014285714;\n\t}\n\n\t/**\n\t * Determines the {@link Screen} on which a {@link Node} is displayed.\n\t *\n\t * @param node the node for which to determine the associated screen, can be null\n\t * @return the screen where the node is located, or the primary screen if the node is null or not associated with a specific screen\n\t */\n\tpublic static Screen getScreen(Node node)\n\t{\n\t\tif (node == null)\n\t\t{\n\t\t\treturn Screen.getPrimary();\n\t\t}\n\t\tvar bounds = node.localToScreen(node.getLayoutBounds());\n\t\tif (bounds == null)\n\t\t{\n\t\t\treturn Screen.getPrimary();\n\t\t}\n\t\tvar rect = new Rectangle2D(bounds.getMinX(), bounds.getMinY(), bounds.getWidth(), bounds.getHeight());\n\t\treturn Screen.getScreens().stream()\n\t\t\t\t.filter(screen -> screen.getBounds().intersects(rect))\n\t\t\t\t.findFirst()\n\t\t\t\t.orElse(Screen.getPrimary());\n\t}\n\n\t/**\n\t * Removes ImageView's output scaling so that it's not zoomed in on 4K monitors.\n\t *\n\t * @param imageView the imageview\n\t * @param parent    the parent's node. can be null, in that case the primary screen is used, but this should be avoided\n\t */\n\tpublic static void disableOutputScaling(ImageView imageView, Node parent)\n\t{\n\t\tObjects.requireNonNull(imageView);\n\n\t\tvar screen = getScreen(parent);\n\t\tif (screen == null)\n\t\t{\n\t\t\tlog.warn(\"Failed to get screen while trying to disable output scaling\");\n\t\t\treturn;\n\t\t}\n\n\t\tvar image = imageView.getImage();\n\t\tif (image == null)\n\t\t{\n\t\t\tlog.warn(\"Failed to get image while trying to disable output scaling\");\n\t\t\treturn;\n\t\t}\n\n\t\timageView.setFitWidth(image.getWidth() / screen.getOutputScaleX());\n\t\timageView.setFitHeight(image.getHeight() / screen.getOutputScaleY());\n\t}\n\n\t/**\n\t * Adds a context menu action to an image with view fullscreen, save as and copy to clipboard.\n\t * @param node the node to add the context menu to\n\t */\n\tpublic static void addImageContextMenuActions(Node node)\n\t{\n\t\tnode.setOnContextMenuRequested(event -> {\n\t\t\tcontextMenu.show(node, event.getScreenX(), event.getScreenY());\n\t\t\tevent.consume();\n\t\t});\n\t\tUiUtils.setOnPrimaryMouseClicked(node, ImageViewUtils::view);\n\t}\n\n\tprivate static void copyToClipboard(ActionEvent event)\n\t{\n\t\tvar selectedMenuItem = (MenuItem) event.getTarget();\n\n\t\tvar popup = Objects.requireNonNull(selectedMenuItem.getParentPopup());\n\t\tClipboardUtils.copyImageToClipboard(((ImageView) popup.getOwnerNode()).getImage());\n\t}\n\n\tprivate static void saveAs(ActionEvent event)\n\t{\n\t\tSaveFormat saveFormat;\n\n\t\tvar selectedMenuItem = (MenuItem) event.getTarget();\n\n\t\tvar popup = Objects.requireNonNull(selectedMenuItem.getParentPopup());\n\t\tvar bufferedImage = SwingFXUtils.fromFXImage(((ImageView) popup.getOwnerNode()).getImage(), null);\n\t\tif (bufferedImage == null)\n\t\t{\n\t\t\tUiUtils.showAlert(ERROR, \"Unsupported image format\");\n\t\t\treturn;\n\t\t}\n\t\tif (bufferedImage.getColorModel().hasAlpha())\n\t\t{\n\t\t\tsaveFormat = new SaveFormat(\"PNG\", List.of(\"*.png\"));\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsaveFormat = new SaveFormat(\"JPG\", List.of(\"*.jpg\", \"*.jpeg\", \"*.jfif\"));\n\t\t}\n\n\t\tvar fileChooser = new FileChooser();\n\t\tfileChooser.setTitle(bundle.getString(\"file-requester.save-image-title\"));\n\t\tChooserUtils.setInitialDirectory(fileChooser, OsUtils.getDownloadDir());\n\t\tfileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(saveFormat.format(), saveFormat.extensions()));\n\t\tfileChooser.setInitialFileName(\"Image_\" + DATE_TIME_FILENAME_FORMAT.format(Instant.now()) + saveFormat.getPrimaryExtension());\n\n\t\tvar selectedFile = fileChooser.showSaveDialog(getWindow(event));\n\t\tif (selectedFile != null)\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif (!ImageIO.write(bufferedImage, saveFormat.format(), selectedFile))\n\t\t\t\t{\n\t\t\t\t\tUiUtils.showAlert(ERROR, \"Couldn't find a writer\");\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tUiUtils.showAlert(ERROR, e.getMessage());\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static void view(ActionEvent event)\n\t{\n\t\tvar selectedMenuItem = (MenuItem) event.getTarget();\n\n\t\tvar popup = Objects.requireNonNull(selectedMenuItem.getParentPopup());\n\t\tview((ImageView) popup.getOwnerNode());\n\t}\n\n\tprivate static void view(MouseEvent event)\n\t{\n\t\tif (event.getButton() != MouseButton.PRIMARY)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tview((ImageView) event.getTarget());\n\t}\n\n\tprivate static void view(ImageView imageView)\n\t{\n\t\tvar fullImageView = new ImageView();\n\t\tfullImageView.setPreserveRatio(true);\n\t\tfullImageView.setPickOnBounds(true);\n\t\tfullImageView.setImage(imageView.getImage());\n\n\t\tvar hbox = new HBox(fullImageView);\n\t\tHBox.setHgrow(fullImageView, Priority.ALWAYS);\n\t\thbox.setAlignment(Pos.CENTER);\n\n\t\tvar vbox = new VBox(hbox);\n\t\tVBox.setVgrow(hbox, Priority.ALWAYS);\n\n\t\tvar scene = new Scene(vbox, imageView.getImage().getWidth(), imageView.getImage().getHeight());\n\t\tvar stage = new Stage();\n\t\tstage.setScene(scene);\n\t\tstage.setFullScreen(true);\n\t\tvar prefNode = PreferenceUtils.getPreferences().node(IMAGE_VIEW);\n\t\tif (prefNode.getBoolean(FULL_SCREEN_HINT_DISPLAYED, false))\n\t\t{\n\t\t\tstage.setFullScreenExitHint(\"\"); // Don't show the hint anymore\n\t\t}\n\t\telse\n\t\t{\n\t\t\tprefNode.putBoolean(FULL_SCREEN_HINT_DISPLAYED, true);\n\t\t\tstage.setFullScreenExitHint(bundle.getString(\"content-image.exit\"));\n\t\t}\n\t\tscene.setOnMouseClicked(mouseEvent -> {\n\t\t\tif (mouseEvent.getButton() == MouseButton.PRIMARY)\n\t\t\t{\n\t\t\t\tstage.hide();\n\t\t\t}\n\t\t});\n\t\tscene.setOnKeyPressed(keyEvent -> {\n\t\t\tif (keyEvent.getCode() == KeyCode.ESCAPE)\n\t\t\t{\n\t\t\t\tstage.hide();\n\t\t\t}\n\t\t});\n\t\tstage.show();\n\t}\n\n\tprivate record SaveFormat(String format, List<String> extensions)\n\t{\n\t\tString getPrimaryExtension()\n\t\t{\n\t\t\treturn extensions.getFirst().substring(1);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/PublicKeyUtils.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\npublic final class PublicKeyUtils\n{\n\tprivate PublicKeyUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static String getKeyAlgorithmName(int algorithm)\n\t{\n\t\treturn switch (algorithm)\n\t\t{\n\t\t\tcase 1 -> \"RSA\";\n\t\t\tcase 2 -> \"RSA Encrypt Only (!)\";\n\t\t\tcase 3 -> \"RSA Sign Only (!)\";\n\t\t\tcase 16 -> \"Elgamal\";\n\t\t\tcase 17 -> \"DSA\";\n\t\t\tcase 18 -> \"ECDH\";\n\t\t\tcase 19 -> \"ECDSA\";\n\t\t\tcase 20 -> \"Elgamal General (!)\";\n\t\t\tcase 21 -> \"Diffie Hellman\";\n\t\t\tcase 22 -> \"EdDSA\";\n\t\t\tcase 23 -> \"AEDH\";\n\t\t\tcase 24 -> \"AEDSA\";\n\t\t\tcase 25 -> \"x25519\";\n\t\t\tcase 26 -> \"x448\";\n\t\t\tcase 27 -> \"Ed25519\";\n\t\t\tcase 28 -> \"Ed448\";\n\t\t\tdefault -> \"Unknown (\" + algorithm + \")\";\n\t\t};\n\t}\n\n\tpublic static String getSignatureHash(int hash)\n\t{\n\t\treturn switch (hash)\n\t\t{\n\t\t\tcase 1 -> \"MD5\";\n\t\t\tcase 2 -> \"SHA-1\";\n\t\t\tcase 3 -> \"RIPEMD-160\";\n\t\t\tcase 8 -> \"SHA-256\";\n\t\t\tcase 9 -> \"SHA-384\";\n\t\t\tcase 10 -> \"SHA-512\";\n\t\t\tcase 11 -> \"SHA-224\";\n\t\t\tcase 12 -> \"SHA3-256\";\n\t\t\tcase 14 -> \"SHA3-512\";\n\t\t\tdefault -> \"Unknown (\" + hash + \")\";\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/Range.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport java.util.Map;\nimport java.util.regex.Matcher;\n\n/**\n * Helper class to handle ranges of text. Provide a matcher then you can find the start and end of the range but\n * also the surrounding parts.\n */\npublic class Range\n{\n\tprivate int start;\n\tprivate final int end;\n\tprivate int group;\n\tprivate String groupName;\n\n\tpublic Range(Matcher matcher)\n\t{\n\t\tif (matcher.groupCount() > 0)\n\t\t{\n\t\t\tfor (var i = 1; i <= matcher.groupCount(); i++)\n\t\t\t{\n\t\t\t\tstart = matcher.start(i);\n\t\t\t\tif (start != -1)\n\t\t\t\t{\n\t\t\t\t\tgroup = i;\n\t\t\t\t\tgroupName = matcher.namedGroups().entrySet().stream()\n\t\t\t\t\t\t\t.filter(entry -> entry.getValue().equals(group))\n\t\t\t\t\t\t\t.map(Map.Entry::getKey)\n\t\t\t\t\t\t\t.findFirst()\n\t\t\t\t\t\t\t.orElse(\"\");\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tstart = matcher.start();\n\t\t}\n\t\tend = matcher.end(group);\n\t}\n\n\tpublic Range(int start, int end)\n\t{\n\t\tthis.start = start;\n\t\tthis.end = end;\n\t}\n\n\tpublic boolean hasRange()\n\t{\n\t\treturn end > start;\n\t}\n\n\tpublic Range outerRange(Range other)\n\t{\n\t\tif (other.start > start)\n\t\t{\n\t\t\t// other is after us\n\t\t\treturn new Range(end, other.start);\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// other is before us\n\t\t\treturn new Range(other.end, start);\n\t\t}\n\t}\n\n\tpublic int start()\n\t{\n\t\treturn start;\n\t}\n\n\tpublic int end()\n\t{\n\t\treturn end;\n\t}\n\n\tpublic int group()\n\t{\n\t\treturn group;\n\t}\n\n\tpublic String groupName()\n\t{\n\t\treturn groupName;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/SmileyUtils.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport java.util.Map;\n\nimport static java.util.Map.entry;\n\n/**\n * Class to handle smiley to emoticon conversion.<br>\n *\n * @see <a href=\"https://en.wikipedia.org/wiki/List_of_emoticons\">List of emoticons</a>\n * @see <a href=\"https://www.webfx.com/tools/emoji-cheat-sheet/\">Emoji cheat sheet</a>\n */\npublic final class SmileyUtils\n{\n\tprivate static final String /* 🙂 */ SLIGHTLY_SMILING_FACE = Character.toString(0x1F642);\n\tprivate static final String /* 😃 */ GRINNING_FACE_WITH_BIG_EYES = Character.toString(0x1F603);\n\tprivate static final String /* 😄 */ GRINNING_FACE_WITH_SMILING_EYES = Character.toString(0x1F604);\n\tprivate static final String /* 😅 */ GRINNING_FACE_WITH_SWEAT = Character.toString(0x1F605);\n\tprivate static final String /* 😂 */ FACE_WITH_TEARS_OF_JOY = Character.toString(0x1F602);\n\tprivate static final String /* 😠 */ ANGRY_FACE = Character.toString(0x1F620);\n\tprivate static final String /* 😶 */ FACE_WITHOUT_MOUTH = Character.toString(0x1F636);\n\tprivate static final String /* 😵 */ FACE_WITH_CROSSED_OUT_EYES = Character.toString(0x1F635);\n\tprivate static final String /* 😳 */ FLUSHED_FACE = Character.toString(0x1F633);\n\tprivate static final String /* 🙁 */ SLIGHTLY_FROWNING_FACE = Character.toString(0x1F641);\n\tprivate static final String /* 😮 */ FACE_WITH_OPEN_MOUTH = Character.toString(0x1F62E);\n\tprivate static final String /* 😘 */ FACE_BLOWING_A_KISS = Character.toString(0x1F618);\n\tprivate static final String /* 😉 */ WINKING_FACE = Character.toString(0x1F609);\n\tprivate static final String /* 😥 */ SAD_BUT_RELIEVED_FACE = Character.toString(0x1F625);\n\tprivate static final String /* 😛 */ FACE_WITH_TONGUE = Character.toString(0x1F61B);\n\tprivate static final String /* 😕 */ CONFUSED_FACE = Character.toString(0x1F615);\n\tprivate static final String /* 😇 */ SMILING_FACE_WITH_HALO = Character.toString(0x1F607);\n\tprivate static final String /* 😈 */ SMILING_FACE_WITH_HORNS = Character.toString(0x1F608);\n\tprivate static final String /* 😎 */ SMILING_FACE_WITH_SUNGLASSES = Character.toString(0x1F60E);\n\tprivate static final String /* 💖 */ SPARKLING_HEART = Character.toString(0x1F496);\n\tprivate static final String /* 🤡 */ CLOWN_FACE = Character.toString(0x1F921);\n\n\tprivate static final Map<String, String> smileys = Map.ofEntries(\n\t\t\tentry(\":-)\", SLIGHTLY_SMILING_FACE),\n\t\t\tentry(\":)\", SLIGHTLY_SMILING_FACE),\n\t\t\tentry(\":-D\", GRINNING_FACE_WITH_BIG_EYES),\n\t\t\tentry(\":D\", GRINNING_FACE_WITH_BIG_EYES),\n\t\t\tentry(\":-DD\", GRINNING_FACE_WITH_SMILING_EYES),\n\t\t\tentry(\":DD\", GRINNING_FACE_WITH_SMILING_EYES),\n\t\t\tentry(\"':)\", GRINNING_FACE_WITH_SWEAT),\n\t\t\tentry(\"':-)\", GRINNING_FACE_WITH_SWEAT),\n\t\t\tentry(\"':D\", GRINNING_FACE_WITH_SWEAT),\n\t\t\tentry(\"':-D\", GRINNING_FACE_WITH_SWEAT),\n\t\t\tentry(\":')\", FACE_WITH_TEARS_OF_JOY),\n\t\t\tentry(\":'-)\", FACE_WITH_TEARS_OF_JOY),\n\t\t\tentry(\":-(\", SLIGHTLY_FROWNING_FACE),\n\t\t\tentry(\":(\", SLIGHTLY_FROWNING_FACE),\n\t\t\tentry(\":-O\", FACE_WITH_OPEN_MOUTH),\n\t\t\tentry(\":O\", FACE_WITH_OPEN_MOUTH),\n\t\t\tentry(\":-*\", FACE_BLOWING_A_KISS),\n\t\t\tentry(\":*\", FACE_BLOWING_A_KISS),\n\t\t\tentry(\";-)\", WINKING_FACE),\n\t\t\tentry(\";)\", WINKING_FACE),\n\t\t\tentry(\";-(\", SAD_BUT_RELIEVED_FACE),\n\t\t\tentry(\";(\", SAD_BUT_RELIEVED_FACE),\n\t\t\tentry(\":-P\", FACE_WITH_TONGUE),\n\t\t\tentry(\":P\", FACE_WITH_TONGUE),\n\t\t\tentry(\":p\", FACE_WITH_TONGUE),\n\t\t\tentry(\":-/\", CONFUSED_FACE),\n\t\t\tentry(\":/\", CONFUSED_FACE),\n\t\t\tentry(\"O:-)\", SMILING_FACE_WITH_HALO),\n\t\t\tentry(\"O:)\", SMILING_FACE_WITH_HALO),\n\t\t\tentry(\">:-)\", SMILING_FACE_WITH_HORNS),\n\t\t\tentry(\">:)\", SMILING_FACE_WITH_HORNS),\n\t\t\tentry(\"B-)\", SMILING_FACE_WITH_SUNGLASSES),\n\t\t\tentry(\"B)\", SMILING_FACE_WITH_SUNGLASSES),\n\t\t\tentry(\":@\", ANGRY_FACE),\n\t\t\tentry(\":-X\", FACE_WITHOUT_MOUTH),\n\t\t\tentry(\":-x\", FACE_WITHOUT_MOUTH),\n\t\t\tentry(\":X\", FACE_WITHOUT_MOUTH),\n\t\t\tentry(\":x\", FACE_WITHOUT_MOUTH),\n\t\t\tentry(\"%-)\", FACE_WITH_CROSSED_OUT_EYES),\n\t\t\tentry(\"%)\", FACE_WITH_CROSSED_OUT_EYES),\n\t\t\tentry(\":-$\", FLUSHED_FACE),\n\t\t\tentry(\":$\", FLUSHED_FACE),\n\t\t\tentry(\"<3\", SPARKLING_HEART),\n\t\t\tentry(\":o)\", CLOWN_FACE)\n\t);\n\n\tprivate SmileyUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * A smiley is detected if the following 2 conditions are met:\n\t * <ul>\n\t *     <li>preceded by nothing or a space</li>\n\t *     <li>followed by nothing or a space, a dot, a comma or an end of line</li>\n\t * </ul>\n\t *\n\t * @param s the string\n\t * @return a string with smileys replaced by Unicode emojis\n\t */\n\tpublic static String smileysToUnicode(String s)\n\t{\n\t\tif (s.length() >= 2 && (s.contains(\":\") || s.contains(\";\") || s.contains(\"B\") || s.contains(\"%\") || s.contains(\"<\"))) // optimizations\n\t\t{\n\t\t\tfor (var e : smileys.entrySet())\n\t\t\t{\n\t\t\t\tvar index = 0;\n\n\t\t\t\twhile ((index = s.indexOf(e.getKey(), index)) != -1)\n\t\t\t\t{\n\t\t\t\t\tif (isAlone(index, e.getKey(), s) || isProperlySeparated(index, e.getKey(), s))\n\t\t\t\t\t{\n\t\t\t\t\t\ts = s.substring(0, index) + e.getValue() + s.substring(index + e.getKey().length());\n\t\t\t\t\t\tindex += e.getValue().length();\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tindex += e.getKey().length(); // skip it then\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn s;\n\t}\n\n\tprivate static boolean isAlone(int index, String key, String s)\n\t{\n\t\treturn index == 0 && key.length() == s.length();\n\t}\n\n\tprivate static boolean isProperlySeparated(int index, String key, String s)\n\t{\n\t\treturn beginningIsSeparator(index, s) && (endIsSeparator(index, key, s));\n\t}\n\n\tprivate static boolean beginningIsSeparator(int index, String s)\n\t{\n\t\treturn index == 0 || Character.isSpaceChar(s.charAt(index - 1));\n\t}\n\n\tprivate static boolean endIsSeparator(int index, String key, String s)\n\t{\n\t\tif (index + key.length() == s.length())\n\t\t{\n\t\t\treturn true;\n\t\t}\n\t\tvar c = s.charAt(index + key.length());\n\t\treturn c == '.' || c == ',' || Character.isSpaceChar(c) || c == '\\n';\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/TextFieldUtils.java",
    "content": "/*\n * Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport javafx.scene.control.TextField;\nimport javafx.scene.control.TextFormatter;\n\nimport java.util.regex.Pattern;\n\nimport static org.apache.commons.lang3.StringUtils.isBlank;\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\n\npublic final class TextFieldUtils\n{\n\tprivate static final Pattern HOST_PATTERN = Pattern.compile(\"^([a-zA-Z0-9])?[a-zA-Z0-9.-]{0,253}$\");\n\n\tprivate TextFieldUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static void setNumeric(TextField textField, int minChars, int maxChars)\n\t{\n\t\tif (minChars < 0 || maxChars < 0)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Negative char limits are not supported\");\n\t\t}\n\t\tif (maxChars < minChars)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"maxChars cannot be smaller than minChars\");\n\t\t}\n\n\t\tvar textFormatter = new TextFormatter<String>(change -> {\n\t\t\tvar text = change.getControlNewText();\n\n\t\t\tif (isEmpty(text))\n\t\t\t{\n\t\t\t\treturn change;\n\t\t\t}\n\t\t\ttry\n\t\t\t{\n\t\t\t\tInteger.parseInt(change.getControlNewText());\n\t\t\t\tif (change.getControlNewText().length() >= minChars && change.getControlNewText().length() <= maxChars)\n\t\t\t\t{\n\t\t\t\t\treturn change;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (NumberFormatException _)\n\t\t\t{\n\t\t\t\t// nothing to do\n\t\t\t}\n\t\t\treturn null;\n\t\t});\n\t\ttextField.setTextFormatter(textFormatter);\n\t}\n\n\tpublic static void setHost(TextField textField)\n\t{\n\t\tvar textFormatter = new TextFormatter<String>(change -> HOST_PATTERN.matcher(change.getControlNewText()).matches() ? change : null);\n\t\ttextField.setTextFormatter(textFormatter);\n\t}\n\n\tpublic static String getString(TextField textField)\n\t{\n\t\treturn isBlank(textField.getText()) ? null : textField.getText();\n\t}\n\n\tpublic static int getAsNumber(TextField textField)\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn Integer.parseInt(textField.getText());\n\t\t}\n\t\tcatch (NumberFormatException _)\n\t\t{\n\t\t\treturn 0;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/TextFlowDragSelection.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.micrometer.common.util.StringUtils;\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.util.TextFlowUtils.Options;\nimport javafx.geometry.Point2D;\nimport javafx.scene.Cursor;\nimport javafx.scene.Node;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.input.*;\nimport javafx.scene.text.HitInfo;\nimport javafx.scene.text.TextFlow;\n\npublic class TextFlowDragSelection\n{\n\tprivate static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN);\n\n\tprivate final TextFlow textFlow;\n\n\tprivate HitInfo firstHitInfo;\n\n\tprivate TextSelectRange textSelectRange;\n\n\tprivate ContextMenu contextMenu;\n\n\t/**\n\t * Enables the selection.\n\t *\n\t * @param textFlow     the textflow to enable the selection for, a context menu with \"Copy\" is automatically added\n\t * @param keyContainer the optional container (usually a pane) that can handle the key presses to enable CTRL-C, if null then there will be no handling of such key combination\n\t */\n\tpublic static void enableSelection(TextFlow textFlow, Node keyContainer)\n\t{\n\t\tvar selection = new TextFlowDragSelection(textFlow);\n\t\ttextFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, selection::press);\n\t\ttextFlow.addEventFilter(MouseEvent.MOUSE_DRAGGED, selection::drag);\n\t\ttextFlow.addEventFilter(MouseEvent.MOUSE_RELEASED, selection::release);\n\t\tif (keyContainer != null)\n\t\t{\n\t\t\tkeyContainer.addEventFilter(KeyEvent.KEY_PRESSED, event -> {\n\t\t\t\tif (COPY_KEY.match(event))\n\t\t\t\t{\n\t\t\t\t\tselection.copy();\n\t\t\t\t\tevent.consume();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t\tvar copyItem = new MenuItem(I18nUtils.getBundle().getString(\"copy\"));\n\t\tcopyItem.setOnAction(_ -> selection.copy());\n\t\tvar contextMenu = new ContextMenu(copyItem);\n\t\ttextFlow.setOnContextMenuRequested(event -> {\n\t\t\tif (selection.textSelectRange != null && selection.textSelectRange.isSelected())\n\t\t\t{\n\t\t\t\tcontextMenu.show(textFlow, event.getScreenX(), event.getScreenY());\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t\tselection.setContextMenu(contextMenu);\n\t}\n\n\tpublic TextFlowDragSelection(TextFlow textFlow)\n\t{\n\t\tthis.textFlow = textFlow;\n\t}\n\n\tprivate void setContextMenu(ContextMenu contextMenu)\n\t{\n\t\tthis.contextMenu = contextMenu;\n\t}\n\n\tpublic void press(MouseEvent e)\n\t{\n\t\tif (e.getEventType() != MouseEvent.MOUSE_PRESSED)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Event must be a MOUSE_PRESSED event\");\n\t\t}\n\n\t\tif (e.getButton() == MouseButton.PRIMARY)\n\t\t{\n\t\t\tif (contextMenu != null)\n\t\t\t{\n\t\t\t\tcontextMenu.hide();\n\t\t\t}\n\t\t\tTextFlowUtils.hideSelection(textFlow);\n\t\t\ttextSelectRange = null;\n\t\t\ttextFlow.setCursor(Cursor.TEXT);\n\n\t\t\tfirstHitInfo = textFlow.getHitInfo(new Point2D(e.getX(), e.getY()));\n\t\t}\n\t}\n\n\tpublic void drag(MouseEvent e)\n\t{\n\t\tif (e.getEventType() != MouseEvent.MOUSE_DRAGGED)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Event must be a MOUSE_DRAGGED event\");\n\t\t}\n\n\t\tif (e.getButton() == MouseButton.PRIMARY)\n\t\t{\n\t\t\tif (firstHitInfo == null)\n\t\t\t{\n\t\t\t\tthrow new IllegalStateException(\"press() wasn't called prior to drag()\");\n\t\t\t}\n\n\t\t\ttextSelectRange = new TextSelectRange(firstHitInfo, textFlow.getHitInfo(new Point2D(e.getX(), e.getY())));\n\t\t\tif (textSelectRange.isSelected())\n\t\t\t{\n\t\t\t\tvar pathElements = textFlow.getRangeShape(textSelectRange.getStart(), textSelectRange.getEnd() + 1, false);\n\t\t\t\tTextFlowUtils.showSelection(textFlow, pathElements);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tTextFlowUtils.hideSelection(textFlow);\n\t\t\t}\n\t\t}\n\n\t}\n\n\tpublic void release(MouseEvent e)\n\t{\n\t\tif (e.getEventType() != MouseEvent.MOUSE_RELEASED)\n\t\t{\n\t\t\tthrow new IllegalArgumentException(\"Event must be a MOUSE_RELEASED event\");\n\t\t}\n\n\t\tif (e.getButton() == MouseButton.PRIMARY)\n\t\t{\n\t\t\ttextFlow.setCursor(Cursor.DEFAULT);\n\n\t\t\tif (textSelectRange == null || !textSelectRange.isSelected())\n\t\t\t{\n\t\t\t\tTextFlowUtils.hideSelection(textFlow);\n\t\t\t\ttextSelectRange = null;\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void copy()\n\t{\n\t\tif (textSelectRange == null || !textSelectRange.isSelected())\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tvar text = TextFlowUtils.getTextFlowAsText(textFlow, textSelectRange.getStart(), textSelectRange.getEnd() + 1, Options.NONE);\n\t\tif (StringUtils.isNotBlank(text))\n\t\t{\n\t\t\tClipboardUtils.copyTextToClipboard(text);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport javafx.scene.Node;\nimport javafx.scene.control.Hyperlink;\nimport javafx.scene.control.Label;\nimport javafx.scene.control.Separator;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.paint.Color;\nimport javafx.scene.shape.Path;\nimport javafx.scene.shape.PathElement;\nimport javafx.scene.text.Text;\nimport javafx.scene.text.TextFlow;\n\nimport java.util.List;\nimport java.util.Objects;\n\npublic final class TextFlowUtils\n{\n\tprivate TextFlowUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic enum Options\n\t{\n\t\tNONE,\n\t\tSPACED_PREFIXES // This lacks flexibility but will do for now\n\t}\n\n\t/**\n\t * Returns a text flow as a string.\n\t *\n\t * @param textFlow   the text flow, not null\n\t * @param beginIndex the beginning index, inclusive\n\t * @param options    the options\n\t * @return the string, not null\n\t */\n\tpublic static String getTextFlowAsText(TextFlow textFlow, int beginIndex, Options options)\n\t{\n\t\tObjects.requireNonNull(textFlow);\n\t\treturn getTextFlowAsText(textFlow, beginIndex, getTextFlowCount(textFlow), options);\n\t}\n\n\t/**\n\t * Returns a text flow as a string.\n\t *\n\t * @param textFlow   the text flow, not null\n\t * @param beginIndex the beginning index, inclusive\n\t * @param endIndex   the ending index, exclusive\n\t * @param options    the options\n\t * @return the string, not null\n\t */\n\tpublic static String getTextFlowAsText(TextFlow textFlow, int beginIndex, int endIndex, Options options)\n\t{\n\t\tvar context = new Context(textFlow.getChildrenUnmodifiable(), beginIndex, endIndex, options == Options.SPACED_PREFIXES ? 2 : 0);\n\t\treturn context.getText();\n\t}\n\n\t/**\n\t * Calculates the length of a textflow.\n\t * <p>Note: only {@link Text} has a length equal to the characters it contains, the other nodes return 1.\n\t *\n\t * @param textFlow the textflow\n\t * @return the length of the textflow\n\t */\n\tpublic static int getTextFlowCount(TextFlow textFlow)\n\t{\n\t\tObjects.requireNonNull(textFlow);\n\t\tvar children = textFlow.getChildrenUnmodifiable();\n\n\t\tvar total = 0;\n\n\t\tfor (var node : children)\n\t\t{\n\t\t\ttotal += getTotalSize(node);\n\t\t}\n\t\treturn total;\n\t}\n\n\t/**\n\t * Shows the selected text visually.\n\t *\n\t * @param textFlow     the text flow\n\t * @param pathElements the path elements, retrieved with {@link TextFlow#getRangeShape(int, int, boolean)}.\n\t */\n\tpublic static void showSelection(TextFlow textFlow, PathElement[] pathElements)\n\t{\n\t\tvar path = new Path(pathElements);\n\t\tpath.setStroke(Color.TRANSPARENT);\n\t\tpath.setFill(Color.DODGERBLUE);\n\t\tpath.setOpacity(0.3);\n\t\tpath.setManaged(false); // This is needed so they show up above\n\t\thideSelection(textFlow);\n\t\ttextFlow.getChildren().add(path);\n\t}\n\n\t/**\n\t * Visually hides all the selected text.\n\t *\n\t * @param textFlow the text flow\n\t */\n\tpublic static void hideSelection(TextFlow textFlow)\n\t{\n\t\tif (textFlow.getChildren().getLast() instanceof Path)\n\t\t{\n\t\t\ttextFlow.getChildren().removeLast();\n\t\t}\n\t}\n\n\tprivate static int getTotalSize(Node node)\n\t{\n\t\treturn switch (node)\n\t\t{\n\t\t\tcase Label ignored -> 1;\n\t\t\tcase Text text -> text.getText().length();\n\t\t\tcase Hyperlink ignored -> 1;\n\t\t\tcase ImageView ignored -> 1;\n\t\t\tcase Separator ignored -> 1;\n\t\t\tcase Path ignored -> 0; // We don't account for that one because it's for marking selected text, and it's always at the end\n\t\t\tdefault -> throw new IllegalStateException(\"Unhandled node: \" + node);\n\t\t};\n\t}\n\n\t/**\n\t * Little helper class to keep track of the context when walking the flow.\n\t */\n\tprivate static class Context\n\t{\n\t\tprivate final List<Node> nodes;\n\t\tprivate final int beginIndex;\n\t\tprivate final int endIndex;\n\t\tprivate final int prefixNeedingSpace;\n\t\tprivate int currentIndex;\n\n\t\tprivate int currentNode = -1;\n\n\t\tpublic Context(List<Node> nodes, int beginIndex, int endIndex, int prefixNeedingSpace)\n\t\t{\n\t\t\tthis.nodes = nodes;\n\t\t\tthis.beginIndex = beginIndex;\n\t\t\tthis.endIndex = endIndex;\n\t\t\tthis.prefixNeedingSpace = prefixNeedingSpace;\n\t\t}\n\n\t\tpublic String getText()\n\t\t{\n\t\t\tvar sb = new StringBuilder();\n\t\t\twhile (hasNextNode())\n\t\t\t{\n\t\t\t\tif (needsSpace() && !sb.isEmpty())\n\t\t\t\t{\n\t\t\t\t\tsb.append(\" \");\n\t\t\t\t}\n\t\t\t\tsb.append(processNextNode());\n\t\t\t}\n\t\t\treturn sb.toString();\n\t\t}\n\n\t\tprivate boolean hasNextNode()\n\t\t{\n\t\t\treturn currentNode + 1 < nodes.size() && !(nodes.get(currentNode + 1) instanceof Path);\n\t\t}\n\n\t\tprivate boolean needsSpace()\n\t\t{\n\t\t\treturn currentNode < prefixNeedingSpace;\n\t\t}\n\n\t\tprivate String processNextNode()\n\t\t{\n\t\t\tcurrentNode++;\n\t\t\tvar node = nodes.get(currentNode);\n\n\t\t\tvar size = getTotalSize(node);\n\n\t\t\tif (currentIndex + size <= beginIndex)\n\t\t\t{\n\t\t\t\tcurrentIndex += size;\n\t\t\t\treturn \"\";\n\t\t\t}\n\t\t\tif (currentIndex >= endIndex)\n\t\t\t{\n\t\t\t\tcurrentIndex += size;\n\t\t\t\treturn \"\";\n\t\t\t}\n\n\t\t\tswitch (node)\n\t\t\t{\n\t\t\t\tcase Label label ->\n\t\t\t\t{\n\t\t\t\t\tcurrentIndex += size;\n\t\t\t\t\treturn label.getText();\n\t\t\t\t}\n\t\t\t\tcase Hyperlink hyperlink ->\n\t\t\t\t{\n\t\t\t\t\tcurrentIndex += size;\n\t\t\t\t\treturn hyperlink.getText();\n\t\t\t\t}\n\t\t\t\tcase ImageView image ->\n\t\t\t\t{\n\t\t\t\t\tcurrentIndex += size;\n\t\t\t\t\tvar imageUserData = image.getUserData();\n\t\t\t\t\treturn imageUserData != null ? (String) imageUserData : \"\";\n\t\t\t\t}\n\t\t\t\tcase Path _ ->\n\t\t\t\t{\n\t\t\t\t\treturn \"\";\n\t\t\t\t}\n\t\t\t\tcase Text text ->\n\t\t\t\t{\n\t\t\t\t\tvar start = 0;\n\t\t\t\t\tvar end = text.getText().length();\n\t\t\t\t\tif (beginIndex >= currentIndex && beginIndex < currentIndex + size)\n\t\t\t\t\t{\n\t\t\t\t\t\tstart = beginIndex - currentIndex;\n\t\t\t\t\t}\n\t\t\t\t\tif (endIndex <= currentIndex + size) // endIndex is always past currentIndex, see above\n\t\t\t\t\t{\n\t\t\t\t\t\tend = endIndex - currentIndex;\n\t\t\t\t\t}\n\t\t\t\t\tcurrentIndex += text.getText().length(); // We don't use end because that way we'll break out of the next run\n\t\t\t\t\treturn text.getText().substring(start, end);\n\t\t\t\t}\n\t\t\t\tdefault -> throw new IllegalStateException(\"Unhandled node: \" + node);\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/TextInputControlUtils.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.rest.location.RSIdResponse;\nimport io.xeres.common.rsid.Type;\nimport io.xeres.ui.client.LocationClient;\nimport io.xeres.ui.support.uri.CertificateUriFactory;\nimport javafx.application.Platform;\nimport javafx.beans.binding.Bindings;\nimport javafx.scene.control.ContextMenu;\nimport javafx.scene.control.MenuItem;\nimport javafx.scene.control.SeparatorMenuItem;\nimport javafx.scene.control.TextInputControl;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.*;\n\nimport java.util.List;\nimport java.util.ResourceBundle;\nimport java.util.function.Consumer;\n\nimport static io.xeres.common.dto.location.LocationConstants.OWN_LOCATION_ID;\n\npublic final class TextInputControlUtils\n{\n\tprivate static final ResourceBundle bundle = I18nUtils.getBundle();\n\n\tprivate TextInputControlUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Adds an enhanced input context menu to TextField or TextArea. It features icons and an optional\n\t * \"Paste own ID\" menu item.\n\t *\n\t * @param textInputControl the text input control\n\t * @param locationClient   the location client, if null, then there will be no \"Paste own ID\" menu item\n\t * @param pasteAction      the action on paste, if null, there will be no action performed\n\t */\n\tpublic static void addEnhancedInputContextMenu(TextInputControl textInputControl, LocationClient locationClient, Consumer<TextInputControl> pasteAction)\n\t{\n\t\tvar contextMenu = new ContextMenu();\n\n\t\tcontextMenu.getItems().addAll(createDefaultChatInputMenuItems(textInputControl, pasteAction));\n\t\tif (locationClient != null)\n\t\t{\n\t\t\tvar pasteId = new MenuItem(bundle.getString(\"paste-id\"));\n\t\t\tpasteId.setGraphic(new FontIcon(MaterialDesignC.CARD_ACCOUNT_DETAILS));\n\t\t\tpasteId.setOnAction(_ -> appendOwnId(textInputControl, locationClient));\n\t\t\tcontextMenu.getItems().addAll(new SeparatorMenuItem(), pasteId);\n\t\t}\n\t\ttextInputControl.setContextMenu(contextMenu);\n\t}\n\n\tpublic static void pasteGuessedContent(TextInputControl textInputControl, String content)\n\t{\n\t\tif (textInputControl.getText().isBlank())\n\t\t{\n\t\t\t//noinspection StatementWithEmptyBody\n\t\t\tif (isUri(content))\n\t\t\t{\n\t\t\t\t// Don't do anything\n\t\t\t}\n\t\t\telse if (isSourceCode(content))\n\t\t\t{\n\t\t\t\tcontent = \"```\\n\" + content + \"\\n```\";\n\t\t\t}\n\t\t\telse if (isCitation(content))\n\t\t\t{\n\t\t\t\tcontent = \"> \" + content;\n\t\t\t}\n\t\t}\n\t\ttextInputControl.insertText(textInputControl.getCaretPosition(), content);\n\t}\n\n\tprivate static void appendOwnId(TextInputControl textInputControl, LocationClient locationClient)\n\t{\n\t\tvar rsIdResponse = locationClient.getRSId(OWN_LOCATION_ID, Type.CERTIFICATE);\n\t\trsIdResponse.subscribe(reply -> Platform.runLater(() -> textInputControl.appendText(buildRetroshareUrl(reply))));\n\t}\n\n\tprivate static String buildRetroshareUrl(RSIdResponse rsIdResponse)\n\t{\n\t\tvar cleanCert = rsIdResponse.rsId().replace(\"\\n\", \"\"); // Removing the '\\n' is in case this is a certificate which is sliced for presentation\n\t\treturn CertificateUriFactory.generate(cleanCert, rsIdResponse.name(), rsIdResponse.location());\n\t}\n\n\tprivate static List<MenuItem> createDefaultChatInputMenuItems(TextInputControl textInputControl, Consumer<TextInputControl> pasteAction)\n\t{\n\t\tvar undo = new MenuItem(bundle.getString(\"undo\"));\n\t\tundo.setGraphic(new FontIcon(MaterialDesignU.UNDO_VARIANT));\n\t\tundo.setOnAction(_ -> textInputControl.undo());\n\n\t\tvar redo = new MenuItem(bundle.getString(\"redo\"));\n\t\tredo.setGraphic(new FontIcon(MaterialDesignR.REDO_VARIANT));\n\t\tredo.setOnAction(_ -> textInputControl.redo());\n\n\t\tvar cut = new MenuItem(bundle.getString(\"cut\"));\n\t\tcut.setGraphic(new FontIcon(MaterialDesignC.CONTENT_CUT));\n\t\tcut.setOnAction(_ -> textInputControl.cut());\n\n\t\tvar copy = new MenuItem(bundle.getString(\"copy\"));\n\t\tcopy.setGraphic(new FontIcon(MaterialDesignC.CONTENT_COPY));\n\t\tcopy.setOnAction(_ -> textInputControl.copy());\n\n\t\tvar paste = new MenuItem(bundle.getString(\"paste\"));\n\t\tpaste.setGraphic(new FontIcon(MaterialDesignC.CONTENT_PASTE));\n\t\tpaste.setOnAction(_ -> {\n\t\t\tif (pasteAction != null)\n\t\t\t{\n\t\t\t\tpasteAction.accept(textInputControl);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\ttextInputControl.paste();\n\t\t\t}\n\t\t});\n\n\t\tvar delete = new MenuItem(bundle.getString(\"delete\"));\n\t\tdelete.setGraphic(new FontIcon(MaterialDesignT.TRASH_CAN));\n\t\tdelete.setOnAction(_ -> textInputControl.deleteText(textInputControl.getSelection()));\n\n\t\tvar selectAll = new MenuItem(bundle.getString(\"select-all\"));\n\t\tselectAll.setGraphic(new FontIcon(MaterialDesignS.SELECT_ALL));\n\t\tselectAll.setOnAction(_ -> textInputControl.selectAll());\n\n\t\tvar emptySelection = Bindings.createBooleanBinding(() -> textInputControl.getSelection().getLength() == 0, textInputControl.selectionProperty());\n\n\t\tcut.disableProperty().bind(emptySelection);\n\t\tcopy.disableProperty().bind(emptySelection);\n\t\tdelete.disableProperty().bind(emptySelection);\n\n\t\tvar emptyText = Bindings.createBooleanBinding(() -> textInputControl.getLength() == 0, textInputControl.textProperty());\n\n\t\tselectAll.disableProperty().bind(emptyText);\n\n\t\tvar canUndo = Bindings.createBooleanBinding(() -> !textInputControl.isUndoable(), textInputControl.undoableProperty());\n\t\tvar canRedo = Bindings.createBooleanBinding(() -> !textInputControl.isRedoable(), textInputControl.redoableProperty());\n\n\t\tundo.disableProperty().bind(canUndo);\n\t\tredo.disableProperty().bind(canRedo);\n\n\t\treturn List.of(undo, redo, cut, copy, paste, delete, new SeparatorMenuItem(), selectAll);\n\t}\n\n\t// Visible for testing\n\tstatic boolean isSourceCode(String text)\n\t{\n\t\tString trimmed = text.trim();\n\n\t\t// Code should only contain ASCII\n\t\tif (!trimmed.chars().allMatch(c -> c <= 127))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\n\t\t// Fenced code block (Markdown), but not if it's at the start\n\t\tif (trimmed.contains(\"```\"))\n\t\t{\n\t\t\treturn !trimmed.startsWith(\"```\");\n\t\t}\n\n\t\t// Multi-line indented block (typical pasted code)\n\t\tString[] lines = trimmed.split(\"\\\\R\");\n\t\tvar indentedLines = 0;\n\t\tfor (String line : lines)\n\t\t{\n\t\t\tif (line.startsWith(\"    \") || line.startsWith(\"\\t\"))\n\t\t\t{\n\t\t\t\tindentedLines++;\n\t\t\t}\n\t\t}\n\t\tif (indentedLines >= 2)\n\t\t{\n\t\t\treturn true;\n\t\t}\n\n\t\t// Semicolon frequency (common in C/Java/JS) and presence of multiple lines\n\t\tlong semicolons = trimmed.chars().filter(c -> c == ';').count();\n\t\tif (semicolons >= 2 && trimmed.contains(\"\\n\"))\n\t\t{\n\t\t\treturn true;\n\t\t}\n\n\t\t// Symbol density heuristic (braces, parentheses, angle brackets, equals, hashes, etc.)\n\t\tvar symbols = \";{}()[]<>#=\\\\*%+-|\";\n\t\tlong specialCount = trimmed.chars().filter(c -> symbols.indexOf(c) >= 0).count();\n\t\tdouble density = (double) specialCount / Math.max(1, trimmed.length());\n\t\treturn density > 0.03 && trimmed.contains(\"\\n\");\n\t}\n\n\t// Visible for testing\n\tstatic boolean isCitation(String text)\n\t{\n\t\treturn text.trim().length() >= 40;\n\t}\n\n\tstatic boolean isUri(String text)\n\t{\n\t\tvar trimmed = text.trim();\n\t\treturn trimmed.startsWith(\"http://\") || trimmed.startsWith(\"https://\") || trimmed.startsWith(\"retroshare://\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/TextSelectRange.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport javafx.scene.text.HitInfo;\n\npublic class TextSelectRange\n{\n\tprivate final int start;\n\tprivate final int end;\n\n\tprivate final boolean isSelected;\n\n\tpublic TextSelectRange(HitInfo firstHit, HitInfo secondHit)\n\t{\n\t\tvar compare = compare(firstHit, secondHit);\n\n\t\tif (compare < 0) // left to right\n\t\t{\n\t\t\tstart = firstHit.getCharIndex();\n\t\t\tend = secondHit.getCharIndex();\n\t\t\tisSelected = true;\n\t\t}\n\t\telse if (compare > 0) // right to left\n\t\t{\n\t\t\tstart = secondHit.getCharIndex();\n\t\t\tend = firstHit.getCharIndex();\n\t\t\tisSelected = true;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tstart = 0;\n\t\t\tend = 0;\n\t\t\tisSelected = false;\n\t\t}\n\t}\n\n\tpublic int getStart()\n\t{\n\t\treturn start;\n\t}\n\n\tpublic int getEnd()\n\t{\n\t\treturn end;\n\t}\n\n\tpublic boolean isSelected()\n\t{\n\t\treturn isSelected;\n\t}\n\n\tprivate static int compare(HitInfo firstHit, HitInfo secondHit)\n\t{\n\t\tif (firstHit == null || secondHit == null)\n\t\t{\n\t\t\treturn 0;\n\t\t}\n\n\t\tif (firstHit.getCharIndex() == secondHit.getCharIndex())\n\t\t{\n\t\t\tif (firstHit.isLeading() == secondHit.isLeading())\n\t\t\t{\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t\telse if (firstHit.isLeading())\n\t\t\t{\n\t\t\t\treturn -1;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\treturn 1;\n\t\t\t}\n\t\t}\n\t\treturn firstHit.getCharIndex() - secondHit.getCharIndex();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/TooltipUtils.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.xeres.ui.custom.DelayedTooltip;\nimport javafx.scene.Node;\nimport javafx.scene.control.Cell;\nimport javafx.scene.control.Tooltip;\nimport javafx.scene.image.ImageView;\nimport javafx.scene.input.MouseEvent;\nimport javafx.util.Duration;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.function.Consumer;\nimport java.util.function.Supplier;\n\npublic final class TooltipUtils\n{\n\tprivate TooltipUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static final Duration DURATION = Duration.minutes(1.0);\n\n\tpublic static void install(@SuppressWarnings(\"rawtypes\") Cell cell, Supplier<String> textSupplier, Supplier<ImageView> graphicSupplier)\n\t{\n\t\tcell.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> {\n\t\t\tif (cell.getItem() == null)\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (textSupplier == null && graphicSupplier == null)\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar text = textSupplier != null ? textSupplier.get() : null;\n\t\t\tif (StringUtils.isBlank(text))\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar tooltip = new Tooltip(text);\n\t\t\tif (graphicSupplier != null)\n\t\t\t{\n\t\t\t\ttooltip.setGraphic(graphicSupplier.get());\n\t\t\t}\n\t\t\tformatTextIfNeeded(tooltip, text);\n\t\t\ttooltip.setShowDuration(DURATION);\n\t\t\tTooltip.install(cell, tooltip);\n\t\t});\n\t\tcell.addEventFilter(MouseEvent.MOUSE_EXITED, event -> {\n\t\t\tif (cell.getItem() != null && cell.getTooltip() != null)\n\t\t\t{\n\t\t\t\tcell.getTooltip().hide();\n\t\t\t\tTooltip.uninstall(cell, cell.getTooltip());\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic static void install(Node node, String text)\n\t{\n\t\tinstall(node, text, false);\n\t}\n\n\tpublic static void install(Node node, String text, boolean immediate)\n\t{\n\t\tvar tooltip = new Tooltip(text);\n\t\ttooltip.setShowDuration(DURATION);\n\t\tif (immediate)\n\t\t{\n\t\t\ttooltip.setShowDelay(Duration.ZERO);\n\t\t}\n\t\tformatTextIfNeeded(tooltip, text);\n\t\tTooltip.install(node, tooltip);\n\t}\n\n\t/**\n\t * Installs a Tooltip that needs to compute what it is going to show only when it's about to\n\t * be shown (for example network call, or heavy computation).\n\t *\n\t * @param node     the node\n\t * @param consumer the consumer that will perform the computation/network call. It has to call {@link DelayedTooltip#show(String)} once it's done to make the tooltip visible\n\t */\n\tpublic static void install(Node node, Consumer<DelayedTooltip> consumer)\n\t{\n\t\tvar tooltip = new DelayedTooltip(consumer);\n\t\ttooltip.setShowDuration(DURATION);\n\t\tTooltip.install(node, tooltip);\n\t}\n\n\tpublic static void uninstall(Node node)\n\t{\n\t\tTooltip.uninstall(node, null);\n\t}\n\n\tprivate static void formatTextIfNeeded(Tooltip tooltip, String text)\n\t{\n\t\tif (text != null && text.length() > 100 && !text.contains(\"\\n\"))\n\t\t{\n\t\t\ttooltip.setMaxWidth(300.0);\n\t\t\ttooltip.setWrapText(true);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/UiUtils.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport atlantafx.base.theme.Styles;\nimport io.xeres.common.AppName;\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.util.ByteUnitUtils;\nimport io.xeres.ui.custom.DisclosedHyperlink;\nimport io.xeres.ui.support.clipboard.ClipboardUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.application.HostServices;\nimport javafx.application.Platform;\nimport javafx.css.PseudoClass;\nimport javafx.event.ActionEvent;\nimport javafx.event.Event;\nimport javafx.geometry.Insets;\nimport javafx.geometry.Pos;\nimport javafx.scene.Node;\nimport javafx.scene.Parent;\nimport javafx.scene.Scene;\nimport javafx.scene.control.*;\nimport javafx.scene.control.Alert.AlertType;\nimport javafx.scene.image.Image;\nimport javafx.scene.input.MouseButton;\nimport javafx.scene.input.MouseEvent;\nimport javafx.scene.layout.*;\nimport javafx.stage.Stage;\nimport javafx.stage.Window;\nimport org.apache.commons.lang3.StringUtils;\nimport org.apache.commons.lang3.SystemUtils;\nimport org.apache.commons.lang3.exception.ExceptionUtils;\nimport org.kordamp.ikonli.javafx.FontIcon;\nimport org.kordamp.ikonli.materialdesign2.MaterialDesignC;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.ProblemDetail;\nimport org.springframework.web.reactive.function.client.WebClientResponseException;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.nio.charset.Charset;\nimport java.text.MessageFormat;\nimport java.time.Instant;\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.function.Consumer;\nimport java.util.stream.Collectors;\n\nimport static io.xeres.ui.support.util.DateUtils.DATE_FORMAT;\nimport static javafx.scene.control.Alert.AlertType.ERROR;\nimport static javafx.scene.control.Alert.AlertType.WARNING;\n\n/**\n * Supplements JavaFX with handy functions for UI operations.\n */\npublic final class UiUtils\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UiUtils.class);\n\n\tprivate UiUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tprivate static final PseudoClass dangerPseudoClass = PseudoClass.getPseudoClass(\"danger\");\n\n\t/**\n\t * Shows a generic alert error. Is supposed to be used in {@code doOnError} in the WebClients.\n\t * Will not block.\n\t *\n\t * @param t the throwable\n\t */\n\tpublic static void webAlertError(Throwable t)\n\t{\n\t\twebAlertError(t, null);\n\t}\n\n\t/**\n\t * Shows a generic alert error and allows to run an action afterwards. Is supposed to be used in\n\t * {@code doOnError} in the WebClients. Will not block.\n\t *\n\t * @param t      the throwable\n\t * @param action the action to perform after the alert has been dismissed, null if no action\n\t */\n\tpublic static void webAlertError(Throwable t, Runnable action)\n\t{\n\t\tPlatform.runLater(() -> {\n\t\t\tif (t instanceof WebClientResponseException e)\n\t\t\t{\n\t\t\t\tvar problem = e.getResponseBodyAs(ProblemDetail.class);\n\t\t\t\tString title;\n\t\t\t\tString detail;\n\t\t\t\tString stackTrace = null;\n\n\t\t\t\tif (problem != null)\n\t\t\t\t{\n\t\t\t\t\ttitle = problem.getTitle();\n\t\t\t\t\tdetail = StringUtils.defaultString(problem.getDetail());\n\t\t\t\t\tvar properties = problem.getProperties();\n\t\t\t\t\tif (properties != null)\n\t\t\t\t\t{\n\t\t\t\t\t\tstackTrace = (String) properties.get(\"trace\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\ttitle = \"Error\";\n\t\t\t\t\tdetail = \"Unknown error\";\n\t\t\t\t}\n\t\t\t\tshowAlert(e.getStatusCode().isError() ? ERROR : WARNING, title, detail, stackTrace);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tshowAlert(ERROR, \"Error\", t.getClass().getSimpleName() + \": \" + t.getMessage(), ExceptionUtils.getStackTrace(t));\n\t\t\t}\n\t\t\tif (action != null)\n\t\t\t{\n\t\t\t\taction.run();\n\t\t\t}\n\t\t});\n\t\tlog.error(\"Error: {}\", t.getMessage(), t);\n\t}\n\n\t/**\n\t * Highlights the specified nodes. Add the 'danger' CSS style to them.\n\t *\n\t * @param nodes the nodes to highlight with errors\n\t */\n\tpublic static void highlightError(Node... nodes)\n\t{\n\t\tfor (var node : nodes)\n\t\t{\n\t\t\tnode.pseudoClassStateChanged(dangerPseudoClass, true);\n\t\t}\n\t}\n\n\t/**\n\t * Clears out the highlighting of the specified nodes. Removes the 'danger' CSS styles to them.\n\t *\n\t * @param nodes the nodes to un-highlight with errors\n\t */\n\tpublic static void clearError(Node... nodes)\n\t{\n\t\tfor (var node : nodes)\n\t\t{\n\t\t\tnode.pseudoClassStateChanged(dangerPseudoClass, false);\n\t\t}\n\t}\n\n\t/**\n\t * Shows an alert. Is supposed to run in the UI thread and will block.\n\t *\n\t * @param alertType the type of the alert\n\t * @param message   the message\n\t */\n\tpublic static void showAlert(AlertType alertType, String message)\n\t{\n\t\tvar alert = buildAlert(alertType, null, message, null);\n\t\talert.showAndWait();\n\t}\n\n\t/**\n\t * Shows an alert with a confirmation. Is supposed to run in the UI thread and will block.\n\t *\n\t * @param message  the message to display\n\t * @param runnable the action to run after the confirmation\n\t */\n\tpublic static void showAlertConfirm(String message, Runnable runnable)\n\t{\n\t\tvar alert = buildAlert(AlertType.CONFIRMATION, null, message, null);\n\t\talert.showAndWait()\n\t\t\t\t.filter(response -> response == ButtonType.OK)\n\t\t\t\t.ifPresent(_ -> runnable.run());\n\t}\n\n\t/**\n\t * Sets the default icon of a stage (once per window).\n\t *\n\t * @param stage the stage to set the default icon to\n\t */\n\tpublic static void setDefaultIcon(Stage stage)\n\t{\n\t\tstage.getIcons().add(new Image(Objects.requireNonNull(stage.getClass().getResourceAsStream(\"/image/icon.png\"))));\n\t}\n\n\t/**\n\t * Sets the default style of a scene (once per window).\n\t *\n\t * @param scene the scene to set the default icon to\n\t */\n\tpublic static void setDefaultStyle(Scene scene)\n\t{\n\t\tscene.getStylesheets().add(\"/view/default.css\");\n\t\tif (SystemUtils.IS_OS_WINDOWS)\n\t\t{\n\t\t\tscene.getStylesheets().add(\"/view/windows.css\");\n\t\t}\n\t\telse if (SystemUtils.IS_OS_LINUX)\n\t\t{\n\t\t\tscene.getStylesheets().add(\"/view/linux.css\");\n\t\t}\n\t\telse if (SystemUtils.IS_OS_MAC)\n\t\t{\n\t\t\tscene.getStylesheets().add(\"/view/mac.css\");\n\t\t}\n\t}\n\n\t/**\n\t * Reads a text file and returns it as a string, preserving line endings.\n\t *\n\t * @param in an input stream\n\t * @return the text file\n\t * @throws IOException I/O error\n\t */\n\tpublic static String getResourceFileAsString(InputStream in) throws IOException\n\t{\n\t\ttry (var isr = new InputStreamReader(in);\n\t\t     var reader = new BufferedReader(isr))\n\t\t{\n\t\t\treturn reader.lines().collect(Collectors.joining(System.lineSeparator()));\n\t\t}\n\t}\n\n\t/**\n\t * Sets a close window actions easily, for example:\n\t * {@snippet :\n\t *     closeButton.setOnAction(UiUtils::closeWindow);\n\t *}\n\t * Beware because not all events contain a node (for example, events from MenuItems).\n\t *\n\t * @param event the event which needs a node in its source\n\t */\n\tpublic static void closeWindow(ActionEvent event)\n\t{\n\t\tcloseWindow((Node) event.getSource());\n\t}\n\n\t/**\n\t * Closes a window using a node.\n\t *\n\t * @param node the node\n\t */\n\tpublic static void closeWindow(Node node)\n\t{\n\t\tvar stage = (Stage) node.getScene().getWindow();\n\t\tstage.close();\n\t}\n\n\t/**\n\t * Makes Hyperlinks actually do something. Slightly recursive.\n\t *\n\t * @param rootNode     the parent node where the hyperlinks are\n\t * @param hostServices the host services\n\t */\n\tpublic static void linkify(Node rootNode, HostServices hostServices)\n\t{\n\t\tif (hostServices == null)\n\t\t{\n\t\t\treturn;\n\t\t}\n\n\t\tswitch (rootNode)\n\t\t{\n\t\t\tcase TabPane tabPane -> tabPane.getTabs().forEach(tab -> linkify(tab.getContent(), hostServices));\n\t\t\tcase ScrollPane scrollPane -> linkify(scrollPane.getContent(), hostServices);\n\t\t\tcase Parent parent -> parent.getChildrenUnmodifiable().forEach(node -> linkify(node, hostServices));\n\t\t\tdefault ->\n\t\t\t{\n\t\t\t}\n\t\t}\n\n\t\tif (rootNode instanceof DisclosedHyperlink disclosedHyperlink)\n\t\t{\n\t\t\tif (disclosedHyperlink.getOnAction() == null)\n\t\t\t{\n\t\t\t\tdisclosedHyperlink.setOnAction(_ -> askBeforeOpeningIfNeeded(disclosedHyperlink, () -> hostServices.showDocument(disclosedHyperlink.getUri())));\n\t\t\t}\n\t\t}\n\t\telse if (rootNode instanceof Hyperlink hyperlink && hyperlink.getOnAction() == null)\n\t\t{\n\t\t\thyperlink.setOnAction(_ -> hostServices.showDocument(hyperlink.getText().contains(\"@\") ? (\"mailto:\" + hyperlink.getText()) : hyperlink.getText()));\n\t\t}\n\t}\n\n\tpublic static void askBeforeOpeningIfNeeded(DisclosedHyperlink hyperlink, Runnable action)\n\t{\n\t\tif (hyperlink.isMalicious())\n\t\t{\n\t\t\tUiUtils.showAlertConfirm(MessageFormat.format(I18nUtils.getBundle().getString(\"uri.malicious-link.confirm\"), hyperlink.getUri()), action);\n\t\t}\n\t\telse if (hyperlink.isUnsafe())\n\t\t{\n\t\t\tUiUtils.showAlertConfirm(MessageFormat.format(I18nUtils.getBundle().getString(\"uri.unsafe-link.confirm\"), hyperlink.getUri()), action);\n\t\t}\n\t\telse\n\t\t{\n\t\t\taction.run();\n\t\t}\n\t}\n\n\t/**\n\t * Gets the window from an event, handles MenuItems as well.\n\t *\n\t * @param event the event\n\t * @return a Window\n\t */\n\tpublic static Window getWindow(Event event)\n\t{\n\t\tvar target = Objects.requireNonNull(event.getTarget(), \"event has no target\");\n\n\t\tswitch (target)\n\t\t{\n\t\t\tcase MenuItem menuItem ->\n\t\t\t{\n\t\t\t\tif (menuItem.getParentMenu() != null)\n\t\t\t\t{\n\t\t\t\t\treturn menuItem.getParentMenu().getParentPopup().getOwnerWindow();\n\t\t\t\t}\n\t\t\t\treturn menuItem.getParentPopup().getOwnerWindow();\n\t\t\t}\n\t\t\tcase Node node ->\n\t\t\t{\n\t\t\t\treturn getWindow(node);\n\t\t\t}\n\t\t\tdefault -> throw new IllegalStateException(\"Cannot find a window from the event \" + event);\n\t\t}\n\t}\n\n\t/**\n\t * Gets the window from a node.\n\t *\n\t * @param node the node to get the window from\n\t * @return the window\n\t */\n\tpublic static Window getWindow(Node node)\n\t{\n\t\treturn node.getScene().getWindow();\n\t}\n\n\t/**\n\t * Sets the presence of a node, that is, if it's visible and takes up space.\n\t *\n\t * @param node    the node\n\t * @param present true if visible, false if gone\n\t */\n\tpublic static void setPresent(Node node, boolean present)\n\t{\n\t\tnode.setManaged(present);\n\t\tnode.setVisible(present);\n\t}\n\n\tpublic static void setAbsent(Node node, boolean absent)\n\t{\n\t\tsetPresent(node, !absent);\n\t}\n\n\t/**\n\t * Puts a node as present, that is, is visible and takes up space.\n\t *\n\t * @param node the node\n\t */\n\tpublic static void setPresent(Node node)\n\t{\n\t\tsetPresent(node, true);\n\t}\n\n\t/**\n\t * Puts a node as absent, that is, is gone.\n\t *\n\t * @param node the node\n\t */\n\tpublic static void setAbsent(Node node)\n\t{\n\t\tsetPresent(node, false);\n\t}\n\n\tpublic static void setOnPrimaryMouseClicked(Node node, Consumer<MouseEvent> consumer)\n\t{\n\t\tnode.setOnMouseClicked(event -> {\n\t\t\tif (event.getButton() == MouseButton.PRIMARY)\n\t\t\t{\n\t\t\t\tconsumer.accept(event);\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic static void setOnPrimaryMouseDoubleClicked(Node node, Consumer<MouseEvent> consumer)\n\t{\n\t\tnode.setOnMouseClicked(event -> {\n\t\t\tif (event.getClickCount() == 2 && event.getButton() == MouseButton.PRIMARY)\n\t\t\t{\n\t\t\t\tconsumer.accept(event);\n\t\t\t\tevent.consume();\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Gets the user data set to a particular node.\n\t *\n\t * @param node the node to get the userdata from\n\t * @return the user data, can be null\n\t */\n\tpublic static Object getUserData(Node node)\n\t{\n\t\tObjects.requireNonNull(node, \"node cannot be null\");\n\t\tvar scene = node.getScene();\n\t\tif (scene != null)\n\t\t{\n\t\t\tvar root = scene.getRoot();\n\t\t\tif (root != null)\n\t\t\t{\n\t\t\t\treturn root.getUserData();\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Sets the size of a {@link FontIcon}.\n\t * <p>ikonli and AtlantaFX don't work well together so this utility method has to be used instead.\n\t * <p>\n\t * See {@link <a href=\"https://github.com/kordamp/ikonli/issues/150\">this issue</a>}.\n\t *\n\t * @param icon the FontIcon\n\t * @param size the size\n\t */\n\tpublic static void setIconSize(FontIcon icon, int size)\n\t{\n\t\tString normalizedStyle = normalizeStyle(icon.getStyle(), \"-fx-font-size\", size + \"px\");\n\t\tnormalizedStyle = normalizeStyle(normalizedStyle, \"-fx-icon-size\", size + \"px\");\n\t\ticon.setStyle(normalizedStyle);\n\t}\n\n\t// Taken from FontIcon()\n\tprivate static String normalizeStyle(String style, String key, String value)\n\t{\n\t\tint start = style.indexOf(key);\n\t\tif (start != -1)\n\t\t{\n\t\t\tint end = style.indexOf(\";\", start);\n\t\t\tend = end >= start ? end : style.length() - 1;\n\t\t\tstyle = style.substring(0, start) + style.substring(end + 1);\n\t\t}\n\t\treturn style + key + \": \" + value + \";\";\n\t}\n\n\tprivate static void showAlert(AlertType alertType, String title, String message, String stackTrace)\n\t{\n\t\tvar alert = buildAlert(alertType, title, message, stackTrace);\n\t\talert.showAndWait();\n\t}\n\n\tprivate static Alert buildAlert(AlertType alertType, String title, String message, String stackTrace)\n\t{\n\t\tvar alert = new Alert(alertType);\n\t\tvar stage = (Stage) alert.getDialogPane().getScene().getWindow();\n\n\t\t// Try to intelligently set the owner window to indicate to the\n\t\t// user that there's some action needed if he clicks it\n\t\tvar defaultOwnerWindow = WindowManager.getDefaultOwnerWindow();\n\t\tif (defaultOwnerWindow != null)\n\t\t{\n\t\t\talert.initOwner(defaultOwnerWindow);\n\t\t}\n\n\t\tUiUtils.setDefaultIcon(stage); // required for the window's title bar icon\n\t\tUiUtils.setDefaultStyle(stage.getScene()); // required for the default styles being applied\n\t\t// Setting dark borders doesn't work because dialogs aren't in JavaFX's built-in windows list\n\t\tif (title != null)\n\t\t{\n\t\t\talert.setTitle(title);\n\t\t}\n\t\talert.setHeaderText(null); // the header is ugly\n\n\t\t// The default doesn't allow cut & pasting and doesn't have scrollbars when needed,\n\t\t// so instead we use a TextArea with similar styling.\n\t\tvar vbox = new VBox();\n\t\tvar hbox = new HBox();\n\t\thbox.setAlignment(Pos.TOP_RIGHT);\n\t\tif (stackTrace != null)\n\t\t{\n\t\t\tvar copyButton = new Button(null, new FontIcon(MaterialDesignC.CLIPBOARD_OUTLINE));\n\t\t\tcopyButton.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT);\n\t\t\tTooltipUtils.install(copyButton, \"Copy as a bug report to the clipboard\");\n\t\t\thbox.getChildren().add(copyButton);\n\t\t\tcopyButton.setOnAction(_ -> ClipboardUtils.copyTextToClipboard(generateAlertErrorString(alertType, message, stackTrace)));\n\t\t}\n\n\t\tvar textArea = new TextArea();\n\t\ttextArea.setWrapText(true);\n\t\ttextArea.setEditable(false);\n\t\ttextArea.setText(message);\n\t\ttextArea.getStyleClass().add(\"alert-textarea\");\n\t\ttextArea.setPrefHeight(StringUtils.defaultString(message).length() < 120 ? 60 : 100); // Should be good enough\n\t\tvbox.setPadding(new Insets(14.0));\n\t\tvbox.getChildren().addAll(hbox, textArea);\n\t\talert.getDialogPane().setContent(vbox);\n\n\t\tif (stackTrace != null)\n\t\t{\n\t\t\tvar ssTextArea = new TextArea(stackTrace);\n\t\t\tssTextArea.getStyleClass().add(\"fixed-font\");\n\t\t\tssTextArea.setWrapText(false);\n\t\t\tssTextArea.setEditable(false);\n\t\t\tssTextArea.setMaxWidth(Double.MAX_VALUE);\n\t\t\tssTextArea.setMaxHeight(Double.MAX_VALUE);\n\t\t\tGridPane.setHgrow(ssTextArea, Priority.ALWAYS);\n\t\t\tGridPane.setVgrow(ssTextArea, Priority.ALWAYS);\n\n\t\t\tvar content = new GridPane();\n\t\t\tcontent.setMaxWidth(Double.MAX_VALUE);\n\t\t\tcontent.add(new Label(\"Full stacktrace:\"), 0, 0);\n\t\t\tcontent.add(ssTextArea, 0, 1);\n\n\t\t\talert.getDialogPane().setExpandableContent(content);\n\t\t}\n\t\talert.getDialogPane().setMinHeight(Region.USE_PREF_SIZE); // Without this, long texts get truncated. Go figure why this isn't the default...\n\n\t\treturn alert;\n\t}\n\n\tprivate static String generateAlertErrorString(AlertType alertType, String message, String stackTrace)\n\t{\n\t\tString version;\n\t\ttry (var resource = UiUtils.class.getClassLoader().getResourceAsStream(\"META-INF/build-info.properties\"))\n\t\t{\n\t\t\tif (resource != null)\n\t\t\t{\n\t\t\t\ttry (var buildInfo = new BufferedReader(new InputStreamReader(resource)))\n\t\t\t\t{\n\t\t\t\t\tversion = buildInfo.lines()\n\t\t\t\t\t\t\t.filter(s -> s.startsWith(\"build.version=\"))\n\t\t\t\t\t\t\t.map(s -> s.substring(\"build.version=\".length()))\n\t\t\t\t\t\t\t.findFirst().orElse(\"unknown\");\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tversion = \"unknown\";\n\t\t\t}\n\t\t}\n\t\tcatch (IOException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\n\t\treturn AppName.NAME + \" Error Report\\n\" +\n\t\t\t\t\"\\nVersion: \" + version +\n\t\t\t\t\"\\nOS: \" + System.getProperty(\"os.name\") + \" (\" + System.getProperty(\"os.arch\") + \")\" +\n\t\t\t\t\"\\nJRE: \" + System.getProperty(\"java.vendor\") + \" (\" + System.getProperty(\"java.version\") + \")\" +\n\t\t\t\t\"\\nCharset: \" + Charset.defaultCharset() +\n\t\t\t\t\"\\nLanguage: \" + Locale.getDefault().getLanguage() +\n\t\t\t\t\"\\nTCP/IP stack state: \" + (StringUtils.defaultString(System.getProperty(\"java.net.preferIPv4Stack\")).equals(\"true\") ? \"sane\" : \"broken\") +\n\t\t\t\t\"\\nNumber of processor threads: \" + Runtime.getRuntime().availableProcessors() +\n\t\t\t\t\"\\nMemory allocated for the JVM: \" + ByteUnitUtils.fromBytes(Runtime.getRuntime().totalMemory()) +\n\t\t\t\t\"\\nMaximum allocatable memory: \" + ByteUnitUtils.fromBytes(Runtime.getRuntime().maxMemory()) +\n\t\t\t\t\"\\nDate: \" + DATE_FORMAT.format(Instant.now()) +\n\t\t\t\t\"\\nSource: requester\" +\n\t\t\t\t\"\\nType: \" + (alertType == ERROR ? \"Error\" : \"Warning\") +\n\t\t\t\t\"\\nMessage: \" + message +\n\t\t\t\t\"\\n\\nStack Trace:\\n\" + stackTrace +\n\t\t\t\t\"\\n\\n\";\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/util/UriUtils.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.web.util.InvalidUrlException;\nimport org.springframework.web.util.UriComponentsBuilder;\n\nimport java.util.List;\nimport java.util.regex.Pattern;\n\npublic final class UriUtils\n{\n\tprivate UriUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * List of allowed safe schemes (other than https, which is checked separately). All the rest must be cautiously handled.\n\t */\n\tprivate static final List<String> ALLOWED_SCHEMES = List.of(\"mailto\", \"tel\", \"retroshare\");\n\n\t/**\n\t * Matches at least one alpha and one dot in the string.\n\t */\n\tprivate static final Pattern ALPHA_CHARACTER = Pattern.compile(\"^(?=.*[a-z])(?=.*\\\\.).*$\");\n\n\t/**\n\t * For a URL to be safe, it must:\n\t * <ul>\n\t * <li>have an allowed scheme (mailto, tel, retroshare or https)\n\t * <li>if it's https, then it must also not specify a port and have a host with at least a dot and an alphabetical lowercase character (and not start with 0x for the hex bypass trick).\n\t * </ul>\n\t *\n\t * @param url the url to check\n\t * @return true if probably safe\n\t */\n\tpublic static boolean isSafeEnough(String url)\n\t{\n\t\tif (StringUtils.isBlank(url))\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\ttry\n\t\t{\n\t\t\tvar uriComponents = UriComponentsBuilder.fromUriString(url)\n\t\t\t\t\t.build();\n\t\t\tvar host = uriComponents.getHost();\n\t\t\tif (host == null && uriComponents.getScheme() == null) // Internal Markdown link\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\tvar port = uriComponents.getPort();\n\t\t\tif (port != -1)\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (ALLOWED_SCHEMES.contains(uriComponents.getScheme()))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\tif (StringUtils.isBlank(host))\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (\"https\".equals(uriComponents.getScheme()))\n\t\t\t{\n\t\t\t\treturn ALPHA_CHARACTER.matcher(host).matches() && !host.startsWith(\"0x\");\n\t\t\t}\n\t\t\tif (\"http\".equals(uriComponents.getScheme()) && (host.endsWith(\".i2p\") || host.endsWith(\".onion\")))\n\t\t\t{\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\tcatch (InvalidUrlException _)\n\t\t{\n\t\t\t// Do nothing, we return false\n\t\t}\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/window/UiNativeWindow.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.window;\n\nimport com.sun.javafx.stage.WindowHelper;\nimport com.sun.jna.*;\nimport com.sun.jna.platform.win32.User32;\nimport com.sun.jna.platform.win32.WinDef;\nimport com.sun.jna.platform.win32.WinNT;\nimport com.sun.jna.platform.win32.WinUser;\nimport javafx.beans.binding.Bindings;\nimport javafx.beans.binding.ObjectBinding;\nimport javafx.geometry.Insets;\nimport javafx.geometry.Point2D;\nimport javafx.scene.Scene;\nimport javafx.scene.layout.Region;\nimport javafx.stage.Stage;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.List;\nimport java.util.Optional;\n\n\n/**\n * Class to handle native functions of windows (dark borders, flashing, ...). Currently only works on Windows.\n */\npublic final class UiNativeWindow\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(UiNativeWindow.class);\n\n\tprivate static final BorderCalculationMethod BORDER_CALCULATION_METHOD = BorderCalculationMethod.LOCAL_BOUNDS;\n\n\tprivate enum BorderCalculationMethod\n\t{\n\t\tLOCAL_BOUNDS,\n\t\tINSETS\n\t}\n\n\tprivate UiNativeWindow()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Sets a window to dark mode.\n\t * @param stage the stage\n\t * @param value true if set to dark mode\n\t */\n\tpublic static void setDarkMode(Stage stage, boolean value)\n\t{\n\t\tif (Platform.getOSType() != Platform.WINDOWS)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tfindWindowHandle(stage).ifPresent(windowHandle -> dwmSetBooleanValue(windowHandle, DwmAttribute.DWMWA_USE_IMMERSIVE_DARK_MODE, value));\n\t}\n\n\t/**\n\t * Flashes the window on the task bar. Does NOT steal the focus, unlike JavaFX's requestFocus().\n\t *\n\t * @param stage the stage\n\t */\n\tpublic static void flashWindow(Stage stage)\n\t{\n\t\tif (Platform.getOSType() != Platform.WINDOWS)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tfindWindowHandle(stage).ifPresent(UiNativeWindow::flash);\n\t}\n\n\t/**\n\t * Sets all currently opened window to dark mode.\n\t * @param value true if dark mode\n\t */\n\tpublic static void setDarkModeAll(boolean value)\n\t{\n\t\tif (Platform.getOSType() != Platform.WINDOWS)\n\t\t{\n\t\t\treturn;\n\t\t}\n\t\tfindAllWindowHandle().forEach(windowHandle -> dwmSetBooleanValue(windowHandle, DwmAttribute.DWMWA_USE_IMMERSIVE_DARK_MODE, value));\n\t}\n\n\t/**\n\t * Calculates the window's decoration sizes (aka the windows borders). To do that, a dummy scene is created and put on an invisible\n\t * window, which is opened, the insets are calculated then the window is closed.<br>\n\t * This only works if Platform.setExplicitExit() is false.\n\t *\n\t * @param stage the primary stage\n\t */\n\tpublic static WindowBorder calculateWindowDecorationSizes(Stage stage)\n\t{\n\t\tif (javafx.application.Platform.isImplicitExit())\n\t\t{\n\t\t\tthrow new IllegalStateException(\"implicit exit must not be set for window decoration calculation to work\");\n\t\t}\n\n\t\t// A dummy scene with an invisible window is created then opened.\n\t\tvar root = new Region();\n\t\tstage.setScene(new Scene(root));\n\t\tstage.setOpacity(0.0);\n\t\tstage.show();\n\n\t\tvar windowBorder = switch (BORDER_CALCULATION_METHOD)\n\t\t{\n\t\t\tcase LOCAL_BOUNDS ->\n\t\t\t{\n\t\t\t\t// This method uses local root bounds and screen bounds.\n\t\t\t\tvar parentRoot = stage.getScene().getRoot();\n\t\t\t\tvar localRootBounds = parentRoot.getBoundsInLocal();\n\t\t\t\tvar localRootTopLeft = new Point2D(localRootBounds.getMinX(), localRootBounds.getMinY());\n\t\t\t\tvar localRootTopRight = new Point2D(localRootBounds.getMaxX(), localRootBounds.getMaxY());\n\t\t\t\tvar localRootBottomLeft = new Point2D(localRootBounds.getMinX(), localRootBounds.getMaxY());\n\t\t\t\tvar screenRootTopLeft = parentRoot.localToScreen(localRootTopLeft);\n\t\t\t\tvar screenRootTopRight = parentRoot.localToScreen(localRootTopRight);\n\t\t\t\tvar screenRootBottomLeft = parentRoot.localToScreen(localRootBottomLeft);\n\n\t\t\t\t// The invisible stage is closed.\n\t\t\t\tstage.hide();\n\t\t\t\tstage.setOpacity(1.0);\n\n\t\t\t\tyield new WindowBorder(screenRootTopLeft.getX() - stage.getX(),\n\t\t\t\t\t\tscreenRootTopLeft.getY() - stage.getY(),\n\t\t\t\t\t\tstage.getX() + stage.getWidth() - screenRootTopRight.getX(),\n\t\t\t\t\t\tstage.getY() + stage.getHeight() - screenRootBottomLeft.getY());\n\t\t\t}\n\t\t\tcase INSETS ->\n\t\t\t{\n\t\t\t\tvar insets = getInsets(stage);\n\n\t\t\t\t// Here we close the invisible stage before performing the calculations.\n\t\t\t\tstage.hide();\n\t\t\t\tstage.setOpacity(1.0);\n\n\t\t\t\tyield new WindowBorder(insets.get().getLeft(),\n\t\t\t\t\t\tinsets.get().getTop(),\n\t\t\t\t\t\tinsets.get().getRight(),\n\t\t\t\t\t\tinsets.get().getBottom());\n\t\t\t}\n\t\t};\n\n\t\tlog.debug(\"Calculated window borders: {}\", windowBorder);\n\t\treturn windowBorder.isEmpty() ? WindowBorder.DEFAULT : windowBorder; // Workaround for Linux where border calculation doesn't work somehow\n\t}\n\n\tprivate static ObjectBinding<Insets> getInsets(Stage stage)\n\t{\n\t\tvar scene = stage.getScene();\n\n\t\treturn Bindings.createObjectBinding(() -> new Insets(scene.getY(),\n\t\t\t\t\t\tstage.getWidth() - scene.getWidth() - scene.getX(),\n\t\t\t\t\t\tstage.getHeight() - scene.getHeight() - scene.getY(),\n\t\t\t\t\t\tscene.getX()),\n\t\t\t\tscene.xProperty(),\n\t\t\t\tscene.yProperty(),\n\t\t\t\tscene.widthProperty(),\n\t\t\t\tscene.heightProperty(),\n\t\t\t\tstage.widthProperty(),\n\t\t\t\tstage.heightProperty());\n\t}\n\n\t/**\n\t * DWM attributes, see: <a href=\"https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute\">the Microsoft API docs</a>\n\t */\n\tprivate enum DwmAttribute\n\t{\n\t\tDWMWA_USE_IMMERSIVE_DARK_MODE(20);\n\n\t\tpublic final int value;\n\n\t\tDwmAttribute(int value)\n\t\t{\n\t\t\tthis.value = value;\n\t\t}\n\t}\n\n\tpublic static final class WindowHandle\n\t{\n\t\tprivate final WinDef.HWND value;\n\n\t\tprivate WindowHandle(WinDef.HWND hwnd)\n\t\t{\n\t\t\tvalue = hwnd;\n\t\t}\n\t}\n\n\tprivate interface DwmSupport extends Library\n\t{\n\t\tDwmSupport INSTANCE = Platform.getOSType() == Platform.WINDOWS ? Native.load(\"dwmapi\", DwmSupport.class) : null;\n\n\t\tWinNT.HRESULT DwmSetWindowAttribute(\n\t\t\t\tWinDef.HWND hwnd,\n\t\t\t\tint dwAttribute,\n\t\t\t\tPointerType pvAttribute,\n\t\t\t\tint cbAttribute\n\t\t);\n\t}\n\n\tprivate static void dwmSetBooleanValue(WindowHandle handle, DwmAttribute attribute, boolean value)\n\t{\n\t\tif (handle != null)\n\t\t{\n\t\t\tDwmSupport.INSTANCE.DwmSetWindowAttribute(\n\t\t\t\t\thandle.value,\n\t\t\t\t\tattribute.value,\n\t\t\t\t\tnew WinDef.BOOLByReference(new WinDef.BOOL(value)),\n\t\t\t\t\tWinDef.BOOL.SIZE\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate static void flash(WindowHandle handle)\n\t{\n\t\tif (handle != null)\n\t\t{\n\t\t\tvar flashInfo = new WinUser.FLASHWINFO();\n\t\t\tflashInfo.hWnd = handle.value;\n\t\t\tflashInfo.dwFlags = WinUser.FLASHW_TRAY;\n\t\t\tflashInfo.uCount = 3;\n\t\t\tflashInfo.dwTimeout = 0;\n\n\t\t\tUser32.INSTANCE.FlashWindowEx(flashInfo);\n\t\t}\n\t}\n\n\tprivate static Optional<WindowHandle> findWindowHandle(Stage stage)\n\t{\n\t\tvar peer = WindowHelper.getPeer(stage);\n\t\tif (peer != null)\n\t\t{\n\t\t\treturn Optional.of(new WindowHandle(new WinDef.HWND(new Pointer(peer.getRawHandle()))));\n\t\t}\n\t\treturn Optional.empty();\n\t}\n\n\tprivate static List<WindowHandle> findAllWindowHandle()\n\t{\n\t\tif (Platform.getOSType() != Platform.WINDOWS)\n\t\t{\n\t\t\treturn List.of();\n\t\t}\n\n\t\treturn com.sun.glass.ui.Window.getWindows().stream()\n\t\t\t\t.map(window -> new WindowHandle(new WinDef.HWND(new Pointer(window.getNativeWindow()))))\n\t\t\t\t.toList();\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/window/WindowBorder.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.window;\n\npublic record WindowBorder(double leftSize, double topSize, double rightSize, double bottomSize)\n{\n\tpublic static final WindowBorder DEFAULT = new WindowBorder(5.0, 20.0, 5.0, 5.0);\n\n\tpublic boolean isEmpty()\n\t{\n\t\treturn leftSize == 0.0 && topSize == 0.0 && rightSize == 0.0 && bottomSize == 0.0 ||\n\t\t\t\tDouble.isNaN(leftSize) && Double.isNaN(topSize) && Double.isNaN(rightSize) && Double.isNaN(bottomSize);\n\t}\n\n\t@Override\n\tpublic String toString()\n\t{\n\t\treturn \"WindowBorder{\" +\n\t\t\t\t\"leftSize=\" + leftSize +\n\t\t\t\t\", topSize=\" + topSize +\n\t\t\t\t\", rightSize=\" + rightSize +\n\t\t\t\t\", bottomSize=\" + bottomSize +\n\t\t\t\t'}';\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/window/WindowManager.java",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.window;\n\nimport io.xeres.common.AppName;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.Identifier;\nimport io.xeres.common.id.LocationIdentifier;\nimport io.xeres.common.id.Sha1Sum;\nimport io.xeres.common.location.Availability;\nimport io.xeres.common.message.chat.ChatAvatar;\nimport io.xeres.common.message.chat.ChatMessage;\nimport io.xeres.common.message.voip.VoipAction;\nimport io.xeres.common.message.voip.VoipMessage;\nimport io.xeres.common.rest.file.AddDownloadRequest;\nimport io.xeres.common.rest.forum.ForumPostRequest;\nimport io.xeres.common.rest.location.RSIdResponse;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.client.preview.PreviewClient;\nimport io.xeres.ui.controller.MainWindowController;\nimport io.xeres.ui.controller.WindowController;\nimport io.xeres.ui.controller.about.AboutWindowController;\nimport io.xeres.ui.controller.account.AccountCreationWindowController;\nimport io.xeres.ui.controller.board.BoardGroupWindowController;\nimport io.xeres.ui.controller.board.BoardMessageWindowController;\nimport io.xeres.ui.controller.channel.ChannelGroupWindowController;\nimport io.xeres.ui.controller.channel.ChannelMessageWindowController;\nimport io.xeres.ui.controller.chat.ChatRoomCreationWindowController;\nimport io.xeres.ui.controller.chat.ChatRoomInvitationWindowController;\nimport io.xeres.ui.controller.debug.DebugRequesterWindowController;\nimport io.xeres.ui.controller.file.FileAddDownloadViewWindowController;\nimport io.xeres.ui.controller.forum.ForumEditorWindowController;\nimport io.xeres.ui.controller.forum.ForumGroupWindowController;\nimport io.xeres.ui.controller.help.HelpWindowController;\nimport io.xeres.ui.controller.id.AddRsIdWindowController;\nimport io.xeres.ui.controller.messaging.BroadcastWindowController;\nimport io.xeres.ui.controller.messaging.MessagingWindowController;\nimport io.xeres.ui.controller.qrcode.CameraWindowController;\nimport io.xeres.ui.controller.qrcode.QrCodeWindowController;\nimport io.xeres.ui.controller.settings.SettingsWindowController;\nimport io.xeres.ui.controller.share.ShareWindowController;\nimport io.xeres.ui.controller.statistics.StatisticsMainWindowController;\nimport io.xeres.ui.controller.voip.VoipWindowController;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.event.OpenUriEvent;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.sound.SoundPlayerService;\nimport io.xeres.ui.support.sound.SoundPlayerService.SoundType;\nimport io.xeres.ui.support.theme.AppThemeManager;\nimport io.xeres.ui.support.uri.*;\nimport io.xeres.ui.support.util.UiUtils;\nimport jakarta.annotation.Nullable;\nimport jakarta.annotation.PreDestroy;\nimport javafx.application.HostServices;\nimport javafx.application.Platform;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport javafx.scene.Scene;\nimport javafx.stage.Modality;\nimport javafx.stage.Stage;\nimport javafx.stage.Window;\nimport net.rgielen.fxweaver.core.FxWeaver;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Component;\nimport reactor.core.Disposable;\n\nimport java.io.IOException;\nimport java.text.MessageFormat;\nimport java.util.*;\nimport java.util.prefs.BackingStoreException;\n\nimport static javafx.scene.control.Alert.AlertType.WARNING;\nimport static org.apache.commons.lang3.StringUtils.isEmpty;\n\n/**\n * Class that tries to overcome the half-assed JavaFX window system.\n */\n@Component\npublic class WindowManager\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(WindowManager.class);\n\n\tprivate static FxWeaver fxWeaver;\n\tprivate final ProfileClient profileClient;\n\tprivate final IdentityClient identityClient;\n\tprivate final MessageClient messageClient;\n\tprivate final ForumClient forumClient;\n\tprivate final BoardClient boardClient;\n\tprivate final ChannelClient channelClient;\n\tprivate final LocationClient locationClient;\n\tprivate final ShareClient shareClient;\n\tprivate final MarkdownService markdownService;\n\tprivate final UriService uriService;\n\tprivate final ChatClient chatClient;\n\tprivate final NotificationClient notificationClient;\n\tprivate final GeneralClient generalClient;\n\tprivate final PreviewClient previewClient;\n\tprivate final ImageCache imageCache;\n\tprivate final SoundPlayerService soundPlayerService;\n\tprivate final HostServices hostServices;\n\tprivate static ResourceBundle bundle;\n\tprivate static AppThemeManager appThemeManager;\n\n\tprivate static WindowBorder windowBorder;\n\tprivate static Window rootWindow;\n\n\tprivate String fullTitle;\n\n\tprivate UiWindow mainWindow;\n\n\tprivate Disposable availabilityNotificationDisposable;\n\n\tprivate boolean isBusy;\n\n\tpublic WindowManager(FxWeaver fxWeaver, ProfileClient profileClient, IdentityClient identityClient, MessageClient messageClient, ForumClient forumClient, BoardClient boardClient, ChannelClient channelClient, LocationClient locationClient, ShareClient shareClient, MarkdownService markdownService, UriService uriService, ChatClient chatClient, NotificationClient notificationClient, GeneralClient generalClient, PreviewClient previewClient, ImageCache imageCache, SoundPlayerService soundPlayerService, @SuppressWarnings(\"SpringJavaInjectionPointsAutowiringInspection\") @Nullable HostServices hostServices, ResourceBundle bundle, AppThemeManager appThemeManager)\n\t{\n\t\tWindowManager.fxWeaver = fxWeaver;\n\t\tthis.profileClient = profileClient;\n\t\tthis.identityClient = identityClient;\n\t\tthis.messageClient = messageClient;\n\t\tthis.forumClient = forumClient;\n\t\tthis.boardClient = boardClient;\n\t\tthis.channelClient = channelClient;\n\t\tthis.locationClient = locationClient;\n\t\tthis.shareClient = shareClient;\n\t\tthis.markdownService = markdownService;\n\t\tthis.uriService = uriService;\n\t\tthis.chatClient = chatClient;\n\t\tthis.notificationClient = notificationClient;\n\t\tthis.generalClient = generalClient;\n\t\tthis.previewClient = previewClient;\n\t\tthis.imageCache = imageCache;\n\t\tthis.soundPlayerService = soundPlayerService;\n\t\tthis.hostServices = hostServices;\n\t\tWindowManager.bundle = bundle;\n\t\tWindowManager.appThemeManager = appThemeManager;\n\t}\n\n\tpublic void setRootWindow(Window window)\n\t{\n\t\trootWindow = window;\n\t}\n\n\tpublic void closeAllWindowsAndExit()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t{\n\t\t\tvar windows = getOpenedWindows();\n\n\t\t\t// There's a strange side effect here when windows are hidden, apparently JavaFX changes the list, so\n\t\t\t// we make a copy.\n\t\t\tvar copyOfWindows = new ArrayList<>(windows);\n\t\t\tlog.debug(\"List of opened windows: {}\", Arrays.toString(copyOfWindows.toArray()));\n\t\t\tcopyOfWindows.forEach(Window::hide);\n\t\t\tPlatform.exit();\n\t\t});\n\t}\n\n\t@EventListener\n\tpublic void handleOpenUriEvents(OpenUriEvent event)\n\t{\n\t\tswitch (event.uri())\n\t\t{\n\t\t\tcase CertificateUri certificateUri -> openAddPeer(certificateUri.radix());\n\t\t\tcase FileUri(String name, long size, Sha1Sum hash) -> openAddDownload(new AddDownloadRequest(name, size, hash, null));\n\t\t\tcase ExternalUri externalUri when hostServices != null ->\n\t\t\t{\n\t\t\t\tvar uriString = externalUri.toUriString();\n\t\t\t\t// If an authority is unknown (for example a Retroshare plugin), then\n\t\t\t\t// it will end up as an external URI. Since we don't want to open a browser\n\t\t\t\t// on an empty URL, we have to check and warn here.\n\t\t\t\tif (uriString.startsWith(\"retroshare://\"))\n\t\t\t\t{\n\t\t\t\t\tUiUtils.showAlert(WARNING, \"The authority for that link is not supported yet.\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\thostServices.showDocument(uriString);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase ChatRoomUri _ ->\n\t\t\t{\n\t\t\t\t// Nothing to do. This is handled in ChatViewController\n\t\t\t}\n\t\t\tcase ForumUri _ ->\n\t\t\t{\n\t\t\t\t// Nothing to do. This is handled in ForumViewController\n\t\t\t}\n\t\t\tcase SearchUri _ ->\n\t\t\t{\n\t\t\t\t// Nothing to do. This is handled in SearchViewController\n\t\t\t}\n\t\t\tcase IdentityUri _, ProfileUri _ ->\n\t\t\t{\n\t\t\t\t// Nothing to do. Those are handled in ContactViewController\n\t\t\t}\n\t\t\tcase ChannelUri _ ->\n\t\t\t{\n\t\t\t\t// Nothing to do. This is handled in ChannelViewController\n\t\t\t}\n\t\t\tcase BoardUri _ ->\n\t\t\t{\n\t\t\t\t// Nothing to do. This is handled in BoardViewController\n\t\t\t}\n\t\t\tdefault -> UiUtils.showAlert(WARNING, \"The link for '\" + event.uri().getClass().getSimpleName().replace(\"Uri\", \"\") + \"' is not supported yet.\");\n\t\t}\n\t}\n\n\tpublic void openMessaging(long locationId)\n\t{\n\t\tlocationClient.findById(locationId)\n\t\t\t\t.doOnSuccess(location -> {\n\t\t\t\t\tassert location != null;\n\t\t\t\t\topenMessaging(location.getLocationIdentifier());\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tpublic void openMessaging(LocationIdentifier locationIdentifier)\n\t{\n\t\topenMessaging(locationIdentifier, null);\n\t}\n\n\tpublic void openMessaging(LocationIdentifier locationIdentifier, ChatMessage chatMessage)\n\t{\n\t\topenMessagingInternal(locationIdentifier, chatMessage);\n\t}\n\n\tpublic void openMessaging(GxsId gxsId)\n\t{\n\t\topenMessaging(gxsId, null);\n\t}\n\n\tpublic void openMessaging(GxsId gxsId, ChatMessage chatMessage)\n\t{\n\t\topenMessagingInternal(gxsId, chatMessage);\n\t}\n\n\tprivate void openMessagingInternal(Identifier destinationIdentifier, ChatMessage chatMessage)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tgetOpenedWindow(MessagingWindowController.class, destinationIdentifier.toString()).ifPresentOrElse(window -> showMessageInExistingWindow(chatMessage, window),\n\t\t\t\t\t\t() -> showMessageInNewWindow(destinationIdentifier, chatMessage)));\n\t}\n\n\tprivate void showMessageInExistingWindow(ChatMessage chatMessage, Window window)\n\t{\n\t\tif (chatMessage == null)\n\t\t{\n\t\t\t// The user opened the window, and it's already open somewhere. Focus it.\n\t\t\tif (!isBusy)\n\t\t\t{\n\t\t\t\twindow.requestFocus();\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// If there's an incoming message, and we aren't working in another part of the\n\t\t\t// app, this will make the taskbar blink if the window is in there.\n\t\t\tif (!chatMessage.isEmpty() && !isAnyWindowFocused() && !isBusy)\n\t\t\t{\n\t\t\t\tif (!window.isFocused())\n\t\t\t\t{\n\t\t\t\t\tsoundPlayerService.play(SoundType.MESSAGE);\n\t\t\t\t\tUiNativeWindow.flashWindow((Stage) window); // We don't use window.requestFocus() because that steals the focus\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t((MessagingWindowController) window.getUserData()).showMessage(chatMessage);\n\t}\n\n\tprivate void showMessageInNewWindow(Identifier destinationIdentifier, ChatMessage chatMessage)\n\t{\n\t\t// Don't open a window for a typing notification, we're not psychic (but do open when we double-click). Don't open for messages sent by us but from another client either\n\t\tif (chatMessage == null || (!chatMessage.isEmpty() && !chatMessage.isOwn()))\n\t\t{\n\t\t\tvar messaging = new MessagingWindowController(profileClient, identityClient, this, uriService, messageClient, shareClient, markdownService, destinationIdentifier, bundle, chatClient, generalClient, previewClient, imageCache, locationClient, chatMessage != null);\n\n\t\t\t// There's no need to store the incoming message anywhere because it's retrieved by the chat backlog system\n\t\t\tvar builder = UiWindow.builder(\"/view/messaging/messaging.fxml\", messaging)\n\t\t\t\t\t.setLocalId(destinationIdentifier.toString())\n\t\t\t\t\t.setRememberEnvironment(true)\n\t\t\t\t\t.build();\n\n\t\t\tif (chatMessage != null && isBusy)\n\t\t\t{\n\t\t\t\tbuilder.openInTaskbar();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tbuilder.open(); // Unfortunately that will always steal the focus, see https://bugs.openjdk.org/browse/JDK-8090742\n\t\t\t\tif (chatMessage != null)\n\t\t\t\t{\n\t\t\t\t\tsoundPlayerService.play(SoundType.MESSAGE);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic void openForumEditor(ForumPostRequest forumPostRequest)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tgetOpenedWindow(ForumEditorWindowController.class, forumPostRequest.toString()).ifPresentOrElse(Window::requestFocus,\n\t\t\t\t\t\t() -> {\n\t\t\t\t\t\t\tvar forumEditor = new ForumEditorWindowController(forumClient, locationClient, markdownService, bundle);\n\n\t\t\t\t\t\t\tUiWindow.builder(\"/view/forum/forum_editor_view.fxml\", forumEditor)\n\t\t\t\t\t\t\t\t\t.setLocalId(forumPostRequest.toString())\n\t\t\t\t\t\t\t\t\t.setTitle(bundle.getString(\"forum.new-message.window-title\"))\n\t\t\t\t\t\t\t\t\t.setUserData(forumPostRequest)\n\t\t\t\t\t\t\t\t\t.build()\n\t\t\t\t\t\t\t\t\t.open();\n\t\t\t\t\t\t}));\n\t}\n\n\tpublic void openBoardMessage(long boardId)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tgetOpenedWindow(BoardMessageWindowController.class, String.valueOf(boardId)).ifPresentOrElse(Window::requestFocus,\n\t\t\t\t\t\t() -> {\n\t\t\t\t\t\t\tvar boardEditor = new BoardMessageWindowController(boardClient, locationClient, markdownService, bundle);\n\n\t\t\t\t\t\t\tUiWindow.builder(\"/view/board/board_message_view.fxml\", boardEditor)\n\t\t\t\t\t\t\t\t\t.setLocalId(String.valueOf(boardId))\n\t\t\t\t\t\t\t\t\t.setTitle(bundle.getString(\"board.new-message.window-title\"))\n\t\t\t\t\t\t\t\t\t.setUserData(boardId)\n\t\t\t\t\t\t\t\t\t.build()\n\t\t\t\t\t\t\t\t\t.open();\n\t\t\t\t\t\t})\n\t\t);\n\t}\n\n\tpublic void openChannelMessage(long channelId)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tgetOpenedWindow(ChannelMessageWindowController.class, String.valueOf(channelId)).ifPresentOrElse(Window::requestFocus,\n\t\t\t\t\t\t() -> {\n\t\t\t\t\t\t\tvar channelEditor = new ChannelMessageWindowController(channelClient, locationClient, markdownService, shareClient, bundle);\n\n\t\t\t\t\t\t\tUiWindow.builder(\"/view/channel/channel_message_view.fxml\", channelEditor)\n\t\t\t\t\t\t\t\t\t.setLocalId(String.valueOf(channelId))\n\t\t\t\t\t\t\t\t\t.setTitle(bundle.getString(\"channel.new-message.window-title\"))\n\t\t\t\t\t\t\t\t\t.setUserData(channelId)\n\t\t\t\t\t\t\t\t\t.build()\n\t\t\t\t\t\t\t\t\t.open();\n\t\t\t\t\t\t})\n\t\t);\n\t}\n\n\tpublic void sendMessaging(String identifier, ChatAvatar chatAvatar)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tgetOpenedWindow(MessagingWindowController.class, identifier).ifPresent(window ->\n\t\t\t\t\t\t((MessagingWindowController) window.getUserData()).showAvatar(chatAvatar)\n\t\t\t\t)\n\t\t);\n\t}\n\n\tpublic void sendMessaging(String identifier, Availability availability)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tgetOpenedWindow(MessagingWindowController.class, identifier).ifPresent(window ->\n\t\t\t\t\t\t((MessagingWindowController) window.getUserData()).setAvailability(availability)\n\t\t\t\t)\n\t\t);\n\t}\n\n\tpublic void doVoip(String identifier, VoipMessage voipMessage)\n\t{\n\t\tif (isBusy && voipMessage != null)\n\t\t{\n\t\t\t// We ignore incoming calls\n\t\t\tif (voipMessage.getAction() == VoipAction.RING)\n\t\t\t{\n\t\t\t\tlog.info(\"Ignored VoIP call from {} because we are busy\", identifier);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// But if this was a call from us (while being busy), honor the close event, without making the window active, though (we're \"busy\")\n\t\t\telse if (voipMessage.getAction() == VoipAction.CLOSE)\n\t\t\t{\n\t\t\t\tPlatform.runLater(() -> getOpenedWindow(VoipWindowController.class).ifPresent(window -> ((VoipWindowController) window.getUserData()).doAction(identifier, voipMessage)));\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tPlatform.runLater(() -> getOpenedWindow(VoipWindowController.class).ifPresentOrElse(window -> {\n\t\t\t\t\twindow.requestFocus();\n\t\t\t\t\t((VoipWindowController) window.getUserData()).doAction(identifier, voipMessage);\n\t\t\t\t},\n\t\t\t\t() -> UiWindow.builder(VoipWindowController.class)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"voip.window-title\"))\n\t\t\t\t\t\t.setUserData(new VoipWindowController.Parameters(identifier, voipMessage))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open()));\n\t}\n\n\tpublic void openAbout()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(AboutWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(MessageFormat.format(bundle.getString(\"about.window-title\"), AppName.NAME))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openThemeExample()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(DebugRequesterWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(\"Xeres Theme\")\n\t\t\t\t\t\t.setResizeable(false)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openDocumentation(boolean rememberPosition)\n\t{\n\t\tPlatform.runLater(() -> {\n\t\t\tvar help = getOpenedWindow(HelpWindowController.class).orElse(null);\n\t\t\tif (help != null)\n\t\t\t{\n\t\t\t\thelp.requestFocus();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tUiWindow.builder(HelpWindowController.class)\n\t\t\t\t\t\t.setRememberEnvironment(rememberPosition)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"help\"))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open();\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void openShare()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(ShareWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"share.window-title\"))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openQrCode(RSIdResponse rsIdResponse)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(QrCodeWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"qr-code.window-title\"))\n\t\t\t\t\t\t.setUserData(rsIdResponse)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openCamera(AddRsIdWindowController parentController)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(CameraWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"camera.window-title\"))\n\t\t\t\t\t\t.setResizeable(false)\n\t\t\t\t\t\t.setUserData(parentController)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openChatRoomCreation()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(ChatRoomCreationWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"chat.room.create.window-title\"))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openBroadcast()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(BroadcastWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"broadcast.window-title\"))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openStatistics()\n\t{\n\t\tPlatform.runLater(() -> {\n\t\t\tvar stats = getOpenedWindow(StatisticsMainWindowController.class).orElse(null);\n\t\t\tif (stats != null)\n\t\t\t{\n\t\t\t\tstats.requestFocus();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tUiWindow.builder(StatisticsMainWindowController.class)\n\t\t\t\t\t\t.setRememberEnvironment(true)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"statistics.window-title\"))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open();\n\t\t\t}\n\t\t});\n\t}\n\n\tpublic void openSettings()\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(SettingsWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"settings\"))\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openAddPeer()\n\t{\n\t\topenAddPeer(null);\n\t}\n\n\tpublic void openAddPeer(String rsId)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(AddRsIdWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"rs-id.add.window-title\"))\n\t\t\t\t\t\t.setUserData(rsId)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openAddDownload(AddDownloadRequest addDownloadRequest)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(FileAddDownloadViewWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"download-add.window-title\"))\n\t\t\t\t\t\t.setUserData(addDownloadRequest)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openInvite(long chatRoom)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(ChatRoomInvitationWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"chat.room.invite.window-title\"))\n\t\t\t\t\t\t.setUserData(chatRoom)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openForumCreation(long groupId)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(ForumGroupWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"forum.create.window-title\"))\n\t\t\t\t\t\t.setUserData(groupId)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openBoardCreation(long groupId)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(BoardGroupWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"board.create.window-title\"))\n\t\t\t\t\t\t.setUserData(groupId)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic void openChannelCreation(long groupId)\n\t{\n\t\tPlatform.runLater(() ->\n\t\t\t\tUiWindow.builder(ChannelGroupWindowController.class)\n\t\t\t\t\t\t.setParent(rootWindow)\n\t\t\t\t\t\t.setTitle(bundle.getString(\"channel.create.window-title\"))\n\t\t\t\t\t\t.setUserData(groupId)\n\t\t\t\t\t\t.build()\n\t\t\t\t\t\t.open());\n\t}\n\n\tpublic String getFullTitle()\n\t{\n\t\treturn fullTitle;\n\t}\n\n\tpublic void openMain(Stage stage, Profile profile, boolean iconified)\n\t{\n\t\tPlatform.runLater(() -> {\n\n\t\t\tif (mainWindow != null && !iconified)\n\t\t\t{\n\t\t\t\tmainWindow.open();\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tvar location = profile.getLocations().stream().findFirst().orElseThrow();\n\t\t\t\tPreferenceUtils.setLocation(location);\n\n\t\t\t\tappThemeManager.applyCurrentTheme();\n\n\t\t\t\tfullTitle = AppName.NAME + \": \" + profile.getName() + \" @ \" + location.getName();\n\n\t\t\t\tmainWindow = UiWindow.builder(MainWindowController.class)\n\t\t\t\t\t\t.setStage(stage)\n\t\t\t\t\t\t.setRememberEnvironment(true)\n\t\t\t\t\t\t.setTitle(fullTitle)\n\t\t\t\t\t\t.build();\n\n\t\t\t\tsetupAvailabilityNotification();\n\n\t\t\t\tif (!iconified)\n\t\t\t\t{\n\t\t\t\t\tmainWindow.open();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate void setupAvailabilityNotification()\n\t{\n\t\tavailabilityNotificationDisposable = notificationClient.getAvailabilityNotifications()\n\t\t\t\t.doOnNext(sse -> {\n\t\t\t\t\tObjects.requireNonNull(sse.data());\n\n\t\t\t\t\tif (sse.data().locationId() == 1L)\n\t\t\t\t\t{\n\t\t\t\t\t\tisBusy = sse.data().availability() == Availability.BUSY;\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.subscribe();\n\t}\n\n\tpublic Stage getMainStage()\n\t{\n\t\treturn mainWindow.stage;\n\t}\n\n\tpublic void openAccountCreation(Stage stage)\n\t{\n\t\tPlatform.runLater(() -> {\n\t\t\tappThemeManager.applyCurrentTheme();\n\t\t\tUiWindow.builder(AccountCreationWindowController.class)\n\t\t\t\t\t.setStage(stage)\n\t\t\t\t\t.build()\n\t\t\t\t\t.open();\n\t\t});\n\t}\n\n\t/**\n\t * Calculates the window's decoration. This must be performed on the first stage so\n\t * that the next opened windows will have the correct sizes.\n\t *\n\t * @param stage the primary stage\n\t */\n\tpublic void calculateWindowDecorationSizes(Stage stage)\n\t{\n\t\twindowBorder = UiNativeWindow.calculateWindowDecorationSizes(stage);\n\t}\n\n\t/**\n\t * Gets the default owner window. Usually the last focus window otherwise the main window.\n\t *\n\t * @return the default owner window, can be null\n\t */\n\tpublic static Window getDefaultOwnerWindow()\n\t{\n\t\treturn Window.getWindows().stream()\n\t\t\t\t.filter(Window::isFocused)\n\t\t\t\t.findFirst().orElse(rootWindow);\n\t}\n\n\tstatic Optional<Window> getOpenedWindow(Class<? extends WindowController> controllerClass)\n\t{\n\t\treturn Window.getWindows().stream()\n\t\t\t\t.filter(window -> Objects.equals(window.getScene().getRoot().getId(), getWindowClassNameForId(controllerClass)))\n\t\t\t\t.findFirst();\n\t}\n\n\tstatic Optional<Window> getOpenedWindow(Class<? extends WindowController> controllerClass, String localId)\n\t{\n\t\treturn Window.getWindows().stream()\n\t\t\t\t.filter(window -> Objects.equals(window.getScene().getRoot().getId(), getWindowClassNameForId(controllerClass) + \":\" + localId))\n\t\t\t\t.findFirst();\n\t}\n\n\tstatic List<Window> getOpenedWindows()\n\t{\n\t\treturn Window.getWindows();\n\t}\n\n\tstatic boolean isAnyWindowFocused()\n\t{\n\t\treturn Window.getWindows().stream()\n\t\t\t\t.filter(Window::isFocused)\n\t\t\t\t.findFirst().orElse(null) != null;\n\t}\n\n\tprivate static String getWindowClassNameForId(Class<? extends WindowController> javaClass)\n\t{\n\t\tassert javaClass.getSimpleName().endsWith(\"WindowController\");\n\n\t\treturn javaClass.getSimpleName().replace(\"WindowController\", \"\");\n\t}\n\n\tstatic final class UiWindow\n\t{\n\t\tprivate static final Logger log = LoggerFactory.getLogger(UiWindow.class);\n\n\t\tprivate static final String KEY_WINDOW_X = \"PosX\";\n\t\tprivate static final String KEY_WINDOW_Y = \"PosY\";\n\t\tprivate static final String KEY_WINDOW_WIDTH = \"Width\";\n\t\tprivate static final String KEY_WINDOW_HEIGHT = \"Height\";\n\t\tpublic static final String NODE_WINDOWS = \"Windows\";\n\n\t\tfinal Scene scene;\n\t\tfinal Stage stage;\n\n\t\tprivate UiWindow(Builder builder)\n\t\t{\n\t\t\tscene = new Scene(builder.root);\n\t\t\tUiUtils.setDefaultStyle(scene);\n\t\t\tstage = Objects.requireNonNullElseGet(builder.stage, Stage::new);\n\t\t\tUiUtils.setDefaultIcon(stage);\n\n\t\t\tif (builder.parent != null)\n\t\t\t{\n\t\t\t\tstage.initOwner(builder.parent);\n\t\t\t\tstage.initModality(Modality.WINDOW_MODAL);\n\t\t\t}\n\t\t\tif (builder.localId != null)\n\t\t\t{\n\t\t\t\tif (!builder.root.getId().contains(\":\"))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"LocalId used for unique window \" + builder.root.getId());\n\t\t\t\t}\n\t\t\t\tvar tokens = builder.root.getId().split(\":\");\n\t\t\t\tbuilder.root.setId(tokens[0] + \":\" + builder.localId);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (builder.root.getId().contains(\":\"))\n\t\t\t\t{\n\t\t\t\t\tthrow new IllegalArgumentException(\"Missing localId for non unique window \" + builder.root.getId());\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (builder.userData != null)\n\t\t\t{\n\t\t\t\tbuilder.root.setUserData(builder.userData);\n\t\t\t}\n\n\t\t\t// Set the minimums to the root's minimums + decorations.\n\t\t\tstage.setMinWidth(builder.root.minWidth(-1) + (int) windowBorder.leftSize() + (int) windowBorder.rightSize()); // There's some rounding errors in JavaFX somewhere. int is a bit better\n\t\t\tstage.setMinHeight(builder.root.minHeight(-1) + (int) windowBorder.topSize() + (int) windowBorder.bottomSize());\n\n\t\t\tstage.setTitle(builder.title);\n\t\t\tstage.setScene(scene);\n\n\t\t\tloadWindowPreferences(stage, builder);\n\n\t\t\tif (!builder.resizeable)\n\t\t\t{\n\t\t\t\tstage.setResizable(false);\n\t\t\t}\n\n\t\t\tstage.setOnShowing(_ -> builder.controller.onShowing());\n\t\t\tstage.setOnShown(_ -> {\n\t\t\t\tbuilder.controller.onShown();\n\t\t\t\tUiNativeWindow.setDarkMode(stage, appThemeManager.getCurrentTheme().isDark());\n\t\t\t\tWindowResizer.ensureWindowIsOnAScreen(stage);\n\t\t\t});\n\t\t\tstage.setOnHiding(_ -> {\n\t\t\t\tsaveWindowPreferences(stage, builder);\n\t\t\t\tbuilder.controller.onHiding();\n\t\t\t});\n\t\t\tstage.setOnHidden(_ -> builder.controller.onHidden());\n\n\t\t\tscene.getWindow().setUserData(builder.controller);\n\t\t}\n\n\t\tprivate static void loadWindowPreferences(Stage stage, Builder builder)\n\t\t{\n\t\t\tvar id = builder.root.getId();\n\n\t\t\tif (!builder.rememberEnvironment)\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (isEmpty(id))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"A Window requires an ID\");\n\t\t\t}\n\n\t\t\tboolean preferencesExist;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tpreferencesExist = PreferenceUtils.getPreferences().nodeExists(NODE_WINDOWS + \"/\" + id);\n\t\t\t}\n\t\t\tcatch (BackingStoreException e)\n\t\t\t{\n\t\t\t\tlog.debug(\"Error while trying to retrieve Windows' preferences: {}\", e.getMessage());\n\t\t\t\tpreferencesExist = false;\n\t\t\t}\n\n\t\t\tif (preferencesExist)\n\t\t\t{\n\t\t\t\tvar preferences = PreferenceUtils.getPreferences().node(NODE_WINDOWS).node(id);\n\t\t\t\tstage.setX(preferences.getDouble(KEY_WINDOW_X, 0));\n\t\t\t\tstage.setY(preferences.getDouble(KEY_WINDOW_Y, 0));\n\t\t\t\tstage.setWidth(preferences.getDouble(KEY_WINDOW_WIDTH, 0));\n\t\t\t\tstage.setHeight(preferences.getDouble(KEY_WINDOW_HEIGHT, 0));\n\t\t\t}\n\t\t}\n\n\t\tprivate static void saveWindowPreferences(Stage stage, Builder builder)\n\t\t{\n\t\t\tvar id = builder.root.getId();\n\n\t\t\tif (!builder.rememberEnvironment)\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (isEmpty(id))\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"A Window requires an ID\");\n\t\t\t}\n\n\t\t\tvar preferences = PreferenceUtils.getPreferences().node(NODE_WINDOWS).node(id);\n\t\t\tpreferences.putDouble(KEY_WINDOW_X, stage.getX());\n\t\t\tpreferences.putDouble(KEY_WINDOW_Y, stage.getY());\n\t\t\tpreferences.putDouble(KEY_WINDOW_WIDTH, stage.getWidth());\n\t\t\tpreferences.putDouble(KEY_WINDOW_HEIGHT, stage.getHeight());\n\t\t\tlog.debug(\"Saving Window {}, x: {}, y: {}, width: {}, height: {}\", id, stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight());\n\t\t}\n\n\t\t/**\n\t\t * Opens the window.\n\t\t */\n\t\tvoid open()\n\t\t{\n\t\t\tstage.show();\n\t\t}\n\n\t\tvoid openInTaskbar()\n\t\t{\n\t\t\tstage.setIconified(true);\n\t\t\tstage.show();\n\t\t}\n\n\t\t/**\n\t\t * Closes the window.\n\t\t */\n\t\tvoid close()\n\t\t{\n\t\t\tstage.close();\n\t\t}\n\n\t\tstatic Builder builder(Class<? extends WindowController> controllerClass)\n\t\t{\n\t\t\tvar parent = (Parent) fxWeaver.loadView(controllerClass, bundle);\n\t\t\tparent.setId(getWindowClassNameForId(controllerClass));\n\t\t\treturn new Builder(parent, fxWeaver.getBean(controllerClass));\n\t\t}\n\n\t\tstatic Builder builder(String resource, WindowController controller)\n\t\t{\n\t\t\tvar fxmlLoader = new FXMLLoader(UiWindow.class.getResource(resource), bundle);\n\t\t\tfxmlLoader.setController(controller);\n\t\t\tParent parent;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tparent = fxmlLoader.load();\n\t\t\t}\n\t\t\tcatch (IOException e)\n\t\t\t{\n\t\t\t\tthrow new IllegalArgumentException(\"Failed to load FXML: \" + e.getMessage(), e);\n\t\t\t}\n\t\t\tparent.setId(getWindowClassNameForId(controller.getClass()) + \":\" + UUID.randomUUID()); // This is a default ID to enforce uniqueness (if localId is specified, it will be removed)\n\t\t\treturn new Builder(parent, controller);\n\t\t}\n\n\t\t/**\n\t\t * This class is used to build UiWindows.\n\t\t */\n\t\tstatic final class Builder\n\t\t{\n\t\t\tprivate Stage stage;\n\t\t\tprivate final Parent root;\n\t\t\tprivate final WindowController controller;\n\t\t\tprivate Window parent;\n\t\t\tprivate String title = AppName.NAME;\n\t\t\tprivate String localId;\n\t\t\tprivate Object userData;\n\t\t\tprivate boolean rememberEnvironment;\n\t\t\tprivate boolean resizeable = true;\n\n\t\t\tprivate Builder(Parent root, WindowController controller)\n\t\t\t{\n\t\t\t\tthis.root = root;\n\t\t\t\tthis.controller = controller;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Sets a parent for the window, hence making it a modal window.\n\t\t\t *\n\t\t\t * @param parent the parent\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setParent(Window parent)\n\t\t\t{\n\t\t\t\tthis.parent = parent;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Sets a stage for the window. If not provided, a default stage will be created.\n\t\t\t *\n\t\t\t * @param stage the stage\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setStage(Stage stage)\n\t\t\t{\n\t\t\t\tthis.stage = stage;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Sets a title for the window that will be shown in the title bar.\n\t\t\t *\n\t\t\t * @param title the window title\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setTitle(String title)\n\t\t\t{\n\t\t\t\tthis.title = title;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Sets a custom window id\n\t\t\t *\n\t\t\t * @param id the window id\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setLocalId(String id)\n\t\t\t{\n\t\t\t\tlocalId = id;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Remembers the window size and position.\n\t\t\t *\n\t\t\t * @param remember true if remembering is needed (defaults to false)\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setRememberEnvironment(boolean remember)\n\t\t\t{\n\t\t\t\trememberEnvironment = remember;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Allows the window to be resized.\n\t\t\t *\n\t\t\t * @param resizeable true if resizeable, false if fixed (defaults to true)\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setResizeable(boolean resizeable)\n\t\t\t{\n\t\t\t\tthis.resizeable = resizeable;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Sets a user data in the window. Can be used for anything.\n\t\t\t *\n\t\t\t * @param userData the user data\n\t\t\t * @return the builder\n\t\t\t */\n\t\t\tBuilder setUserData(Object userData)\n\t\t\t{\n\t\t\t\tthis.userData = userData;\n\t\t\t\treturn this;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Builds the UiWindow.\n\t\t\t *\n\t\t\t * @return the UiWindow\n\t\t\t */\n\t\t\tUiWindow build()\n\t\t\t{\n\t\t\t\treturn new UiWindow(this);\n\t\t\t}\n\t\t}\n\t}\n\n\t@PreDestroy\n\tprivate void removeNotification()\n\t{\n\t\tif (availabilityNotificationDisposable != null)\n\t\t{\n\t\t\tavailabilityNotificationDisposable.dispose();\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/java/io/xeres/ui/support/window/WindowResizer.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.window;\n\nimport javafx.geometry.Rectangle2D;\nimport javafx.stage.Screen;\nimport javafx.stage.Stage;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * Inspired from <a href=\"https://pablofernandez.tech/2017/12/20/restoring-window-sizes-in-javafx/\">the blog post of Pablo Fernandez</a>\n */\nfinal class WindowResizer\n{\n\tprivate static final Logger log = LoggerFactory.getLogger(WindowResizer.class);\n\n\tprivate static final double MINIMUM_VISIBLE_WIDTH = 100.0;\n\tprivate static final double MINIMUM_VISIBLE_HEIGHT = 50.0;\n\n\tprivate WindowResizer()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\t/**\n\t * Makes sure that the window is actually on a screen.\n\t *\n\t * @param stage the stage\n\t */\n\tpublic static void ensureWindowIsOnAScreen(Stage stage)\n\t{\n\t\tif (isWindowOutOfBounds(stage))\n\t\t{\n\t\t\tlog.debug(\"Window out of bounds, repositioning...\");\n\t\t\tmoveToPrimaryScreen(stage);\n\t\t}\n\t}\n\n\tprivate static boolean isWindowOutOfBounds(Stage stage)\n\t{\n\t\tfor (Screen screen : Screen.getScreens())\n\t\t{\n\t\t\tRectangle2D bounds = screen.getVisualBounds();\n\t\t\tif (stage.getX() + stage.getWidth() - MINIMUM_VISIBLE_WIDTH >= bounds.getMinX() &&\n\t\t\t\t\tstage.getX() + MINIMUM_VISIBLE_WIDTH <= bounds.getMaxX() &&\n\t\t\t\t\tbounds.getMinY() <= stage.getY() && // We want the title bar to always be visible.\n\t\t\t\t\tstage.getY() + MINIMUM_VISIBLE_HEIGHT <= bounds.getMaxY())\n\t\t\t{\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\tprivate static void moveToPrimaryScreen(Stage stage)\n\t{\n\t\tRectangle2D bounds = Screen.getPrimary().getVisualBounds();\n\t\tstage.setX((bounds.getWidth() / 2) - (stage.getWidth() / 2));\n\t\tstage.setY((bounds.getHeight() / 2) - (stage.getHeight() / 2));\n\t}\n}\n"
  },
  {
    "path": "ui/src/main/javadoc/overview.html",
    "content": "<!--\n  ~ Copyright (c) 2024 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<body>\nThis is the UI part of Xeres. It communicates with the server using a REST API. Chat related functions\nuse WebSockets.\n</body>"
  },
  {
    "path": "ui/src/main/resources/help/en/00.Index.md",
    "content": "Select a help topic on the left.\n\nThe home button will bring you back to this page.\n\nThe [Quick Setup](01.Quick%20Setup.md) is a must-read for first time users.\n\nSee the [Links](06.Links.md) topic for online places you can visit to get more information.\n\nAnd remember that you can point your mouse to most UI elements and a tooltip with an explanation will appear after a whort while.\n"
  },
  {
    "path": "ui/src/main/resources/help/en/01.Quick Setup.md",
    "content": "# Creating a profile\n\nIf this is your first time running Xeres, you need to create a **profile** and a **location**.\n\nThe profile is basically yourself (a person) and the location is your machine. You can have several locations, like a desktop and a laptop, each running your profile.\n\nIt's possible to export a profile from your first machine to use it on another. Use the `Tools / Export` menu for that, then import it in the account creation on your other machine.\n\n# Adding Friends\n\nWhile Xeres can run by itself, it's much more interesting when you start connecting to some friends.\n\nThe main concept for that is an exchange of IDs. You give your ID to your friends, and your friends gives their ID to you. Only if you do this exchange, you can be connected together.\n\n![](data:image/jpeg;base64,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)\n\nYou can do that directly from the **Home** panel. Press the following button at the right of your ID to copy it to the clipboard.\n\n![Copy To Clipboard](data:image/jpeg;base64,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)\n\nYou can then paste that ID in the medium of your choice to send to your friend, for example:\n\n- another chat app\n- email\n- SMS\n- text file on a USB stick\n\nOnce your friend gave his ID to you, press the **Add Peer** button.\n\n![Add Peer](data:image/jpeg;base64,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)\n\nThis will bring the following window where you can paste your friend’s ID and press the **Add** button.\n\n![Adding Friend](data:image/jpeg;base64,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CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdAro7T/j1h/3B/Kucro7T/j1h/wBwfyry6uyKiY2p/wDH9L+H8hVWrWpf8fsn4fyFVa1j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXR2n/AB6w/wC4P5VzldDan/Rof9wfyry6uyKiZGpf8fsn4fyFVatal/x+yfh/IVVrWPwol7naeHf+SXeM/wDr607+c9cXXaeHf+SXeM/+vrTv5z1xdd+I/h0f8L/9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv8AkqPhv/r6H8jXF12nwa/5Kj4b/wCvofyNcXXbP/daf+KX5QDoFdDa/wDHtD/uD+Vc9XQ2v/HtD/uD+VeXV2RUTI1L/j9k/D+QqrVrUv8Aj9k/D+QqrWsfhRL3O08O/wDJLvGf/X1p38564uu08O/8ku8Z/wDX1p38564uu/Efw6P+F/8ApcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdArobX/j2h/3B/KuerobX/j2h/wBwfyry6uyKiZGpf8fsn4fyFVatal/x+yfh/IVVrWPwol7naeHf+SXeM/8Ar607+c9cXXaeHf8Akl3jP/r607+c9cXXfiP4dH/C/wD0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/AOvofyNcXXafBr/kqPhv/r6H8jXF12z/AN1p/wCKX5QDoFdDa/8AHtD/ALg/lXPV0Nr/AMe0P+4P5V5dXZFRMjUv+P2T8P5CqtWtS/4/ZPw/kKq1rH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0Cuhtf+PaH/cH8q56uhtf+PaH/cH8q8ursiomRqX/AB+yfh/IVVq1qX/H7J+H8hVWtY/CiXudp4d/5Jd4z/6+tO/nPXF12nh3/kl3jP8A6+tO/nPXF134j+HR/wAL/wDS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/AK+h/I1xddp8Gv8AkqPhv/r6H8jXF12z/wB1p/4pflAOgV0Nr/x7Q/7g/lXPV0Nr/wAe0P8AuD+VeXV2RUTI1L/j9k/D+QqrVrUv+P2T8P5Cqtax+FEvc7Tw7/yS7xn/ANfWnfznri67Tw7/AMku8Z/9fWnfznri678R/Do/4X/6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8AJUfDf/X0P5GuLrtPg1/yVHw3/wBfQ/ka4uu2f+60/wDFL8oB0Cuhtf8Aj2h/3B/KuerobX/j2h/3B/KvLq7IqJkal/x+yfh/IVVq1qX/AB+yfh/IVVrWPwol7naeHf8Akl3jP/r607+c9cXXaeHf+SXeM/8Ar607+c9cXXfiP4dH/C//AEuQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/6+h/I1xddp8Gv+So+G/wDr6H8jXF12z/3Wn/il+UA6BXQ2v/HtD/uD+Vc9XQ2v/HtD/uD+VeXV2RUTI1L/AI/ZPw/kKq1a1L/j9k/D+QqrWsfhRL3O08O/8ku8Z/8AX1p38564uu08O/8AJLvGf/X1p38564uu/Efw6P8Ahf8A6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/wDX0P5GuLrtPg1/yVHw3/19D+Rri67Z/wC60/8AFL8oB0Cuhtf+PaH/AHB/KuerobX/AI9of9wfyry6uyKiZGpf8fsn4fyFVatal/x+yfh/IVVrWPwol7naeHf+SXeM/wDr607+c9cXXaeHf+SXeM/+vrTv5z1xdd+I/h0f8L/9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv8AkqPhv/r6H8jXF12nwa/5Kj4b/wCvofyNcXXbP/daf+KX5QDoFdDa/wDHtD/uD+Vc9XQ2v/HtF/uD+VeXV2RUTH1H/j9k/D+QqtVnUf8Aj9k/D+QqtWsfhRL3O08O/wDJLvGf/X1p38564uu08O/8ku8Z/wDX1p38564uu/Efw6P+F/8ApcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdArobX/j2i/3B/KuerobX/j2i/wBwfyry6uyKiY+o/wDH7J+H8hVarOo/8fsn4fyFVq1j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXQ2v/AB7Rf7g/lXPV0Nr/AMe0X+4P5V5dXZFRMfUf+PyT8P5Cq1WdR/4/JPw/kKrVrH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0Ct+2P+jRf7g/lWBW/bf8e0X+4P5V5dXZFRMnUf8Aj8k/D+QqtVnUf+PyT8P5Cq1ax+FEvc7Tw7/yS7xn/wBfWnfznri67Tw7/wAku8Z/9fWnfznri678R/Do/wCF/wDpcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/ANfQ/ka4uu0+DX/JUfDf/X0P5GuLrtn/ALrT/wAUvygHQK37b/j2i/3B/KsCt+2/49ov9wfyry6uyKiZOo/8fkn4fyFVqs6j/wAfkn4fyFVq1j8KJe52nh3/AJJd4z/6+tO/nPXF12nh3/kl3jP/AK+tO/nPXF134j+HR/wv/wBLkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/+vofyNcXXafBr/kqPhv8A6+h/I1xdds/91p/4pflAOgVv23/HtF/uD+VYFb9t/wAe0X+4P5V5dXZFRMnUf+PyT8P5Cq1WdR/4/JPw/kKrVrH4US9ztPDv/JLvGf8A19ad/OeuLrtPDv8AyS7xn/19ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK37b/j2i/wBwfyrArftv+PaL/cH8q8ursiomTqP/AB+Sfh/IVWqzqP8Ax+Sfh/IVWrWPwol7naeHf+SXeM/+vrTv5z1xddp4d/5Jd4z/AOvrTv5z1xdd+I/h0f8AC/8A0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/wCvofyNcXXafBr/AJKj4b/6+h/I1xdds/8Adaf+KX5QDoFb9t/x7Rf7g/lWBW/bf8e0X+4P5V5dXZFRMnUf+PyT8P5Cq1WdR/4/JPw/kKrVrH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0Ct+2/wCPaL/cH8qwK37b/j2i/wBwfyry6uyKiZOo/wDH5J+H8hVarOo/8fkn4fyFVq1j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BW/bf8e0X+4P5VgVv23/HtF/uD+VeXV2RUTJ1H/j8k/D+QqtVnUf8Aj8k/D+QqtWsfhRL3O08O/wDJLvGf/X1p38564uu08O/8ku8Z/wDX1p38564uu/Efw6P+F/8ApcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdArftv+PaL/cH8qwK37b/AI9ov9wfyry6uyKiZOo/8fkn4fyFVqs6j/x+Sfh/IVWrWPwol7naeHf+SXeM/wDr607+c9cXXaeHf+SXeM/+vrTv5z1xdd+I/h0f8L/9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv8AkqPhv/r6H8jXF12nwa/5Kj4b/wCvofyNcXXbP/daf+KX5QDoFb9t/wAe0X+4P5VgVv23/HtF/uD+VeXV2RUTJ1H/AI/JPw/kKrVZ1H/j8k/D+QqtWsfhRL3O08O/8ku8Z/8AX1p38564uu08O/8AJLvGf/X1p38564uu/Efw6P8Ahf8A6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/wDX0P5GuLrtPg1/yVHw3/19D+Rri67Z/wC60/8AFL8oB0Ct+2/49ov9wfyrArftv+PaL/cH8q8ursiomTqP/H5J+H8hVarOo/8AH5J+H8hVatY/CiXudp4d/wCSXeM/+vrTv5z1xddp4d/5Jd4z/wCvrTv5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iF7gCT72FI5HX0rX+Ad1ZeFfh38RvBnh74k+GfBPxLOrQ+R4mmvxBa39hGpDw296V+QFxuyNpbKjkdND4i+KbC1/ZC8U+GdX+K9r8Q/GjeJ7W4mP9qNdfJ5Y4t2lbzJkX+KRVCbiQM4yW276CM+++G3hz4e/G79lGfw/p39ny69ZeG9X1FvPkk8+6luYzJJ87Hbn+6uFHYCq8/wo0n4z/t5+O/Detz3KWDatql21tYuiXF20W9xBGz8BmI64PAPTqNPxz458N3fxZ/ZLvIPEGlTWmiaJ4bi1S4jvY2jsHinQyrOwbERQAlg2CoHOK858faRp/wAUP2rPH76Z470Lw1FNq93e6br99fGOylcTZTbcRhguQSwccfL15oV/wAw/jDr3gRtOn0PTPg/ffDfxRZ3S7pbrWbq5dogCGjlhnUbXztO5cDrwK8gr67+M/jOOP9na/wDDHxE+IHhz4n+PBqFtJ4dvNEuxqNxp1uCTP514EG5WHAQsxyQe3y/IlaQ2EwoooqwCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA3vAn/I36V/12H8jWDW94E/5G/Sv+uw/kawa6ZfwI+svyiAVv23/HtF/uD+VYFb9t/x7Rf7g/lXn1dkVEydR/4/JPw/kKrVZ1H/AI/JPw/kKrVrH4US9ze0v/kUNd/67Wv85Kwa3tL/AORR10d/OtT+slYNddX4Kfp/7dIAooormAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA3vAn/I36V/12H8jWDW94F48XaWewmBP5GsGumX8CPrL8ogFb9t/wAe0X+4P5VgVv23/HtF/uD+VefV2RUTJ1H/AI/JPw/kKrVZ1H/j8k/D+QqtWsfhRL3Nbw/qsWny3EF2jSWN3H5Uyr94c5DD3B5q0/hWOZt1nrGmywHlTNcCFwPQq2MGuforqjVXKoTjdLbuBvf8IhN/0E9J/wDA+P8Axo/4RCb/AKCe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)\n\nOnce this 2-way ID exchange is done, Xeres will connect directly and securely, without using any 3rd party server.\n\nNote: there's also an online [ChatServer](https://retroshare.ch/) available, if you just want to try the software or are looking for friends.\n\nHint: you can also use the QR code button, take a picture with your smartphone and show it to the other instance of Xeres by using the little QR code scanner button in the add peer window."
  },
  {
    "path": "ui/src/main/resources/help/en/02.Network.md",
    "content": "# Connections\n\nThe connection with other friends happens automatically as long as you added them as explained in [Quick Setup](01.Quick%20Setup.md).\n\n![](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAjwCPAAD/4QBmRXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAAExAAIAAAAQAAAATgAAAAAAAACPAAAAAQAAAI8AAAABUGFpbnQuTkVUIDUuMS45AP/bAEMAAgEBAQEBAgEBAQICAgICBAMCAgICBQQEAwQGBQYGBgUGBgYHCQgGBwkHBgYICwgJCgoKCgoGCAsMCwoMCQoKCv/bAEMBAgICAgICBQMDBQoHBgcKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCv/AABEIAd0CIQMBEgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP38ooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKjaZw+xISecZPA6e/9KdieZXs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)\n\nXeres automatically takes care of finding the peers and connecting to them. It can take a little while, from a few seconds to a couple of minutes.\n\nRemember you both need to have added each other's ID, otherwise it won't work.\n\nThe number of connected friends is displayed on the lower left of the main window as well. There's also a DHT and NAT indicator whose function is described below.\n\n## Helpers\n\nTo improve connections in a dynamic and changing network, the following features can be helpful.\n\n### DHT\n\nDHT means Distributed Hash Table and is a system that helps two peers find each other when their IP address has changed or is unknown. Xeres uses the BitTorrent DHT, also known as Mainline DHT. If the LED is not green, then connecting to friends might be more difficult.\n\n### NAT\n\nIf you're behind a NAT (Network Address Translation: most routers are configured with a NAT), then incoming connections might be restricted. Xeres tries to get around this by using the UPNP protocol. Make sure UPNP is enabled on your router. Contrary to popular beliefs, UPNP is secure nowadays as all old bugs have been\nironed out. The NAT LED in Xeres should be green.\n"
  },
  {
    "path": "ui/src/main/resources/help/en/03.Markdown.md",
    "content": "# Markdown Cheat Sheet\n\n## Basic Syntax\n\nMarkdown is supported in forums and chats (the latter ignores heading and horizontal rules, though).\n\n### Heading\n\n\\# Header 1\n\n\\## Header 2\n\n\\### Header 3\n\n(up to Header 6)\n\n### Bold\n\n\\*\\*bold\\*\\* or \\_\\_bold\\_\\_\n\n### Italic\n\n\\*italic\\* or \\_italic\\_\n\n### Blockquote\n\n\\> blockquote\n\n### Ordered List\n\n1. First item\n2. Second item\n3. Third item\n\n### Unordered List\n\n\\- First item  \n\\- Second item  \n\\- Third item\n\n### Code\n\n\\`code`\n\n### Horizontal Rule\n\n\\---\n\n### Link\n\n\\[Markdown Guide](https://www.markdownguide.org)\n\n### Image\n\n\\![alt text]\\(data:image/jpeg;base64,etc...)\n\n## Extended Syntax\n\n### Fenced Code Block\n\n\\`\\`\\`  \n{  \n\"firstName\": \"John\",  \n\"lastName\": \"Smith\",  \n\"age\": 25  \n}  \n\\`\\`\\`\n\n### Strikethrough\n\n\\~~Censored~~\n\n### Emoji\n\nWoohoo! \\:joy\\:\n\n---\n\n## How it looks\n\n# H1\n\n## H2\n\n### H3\n\n**bold**\n\n*italic*\n\n> blockquote\n\n1. First item\n2. Second item\n3. Third item\n\n- First item\n- Second item\n- Third item\n\n`code`\n\n---\n\n[Markdown Guide](https://www.markdownguide.org)\n\n```  \n{  \n\t\"firstName\": \"John\",  \n\t\"lastName\": \"Smith\",  \n\t\"age\": 25  \n}  \n```\n\n~~Censored~~\n\nWoohoo! :joy:"
  },
  {
    "path": "ui/src/main/resources/help/en/04.Emojis.md",
    "content": "# Emojis\n\nAliases can be used to quickly display some emojis. It's preferred to directly insert them using your OS' keyboard shortcut (for example `Win`+`.` on Windows).\n\n### Most common\n\n:joy​: :joy:\n\n:grin​: :grin:\n\n:rofl​: :rofl:\n\n:yum​: :yum:\n\n:blush​: :blush:\n\n:rage​: :rage:\n\n:scream​: :scream:\n\n:cry​: :cry:\n\n:sob​: :sob:\n\n:sick​: :sick:\n\n:poop​: :poop:\n\n:muscle​: :muscle:\n\n:wave​: :wave:\n\n:eyes​: :eyes:\n\n:zzz​: :zzz:\n\n:fire​: :fire:\n\n:heart​: :heart:\n\n:boom​: :boom:\n\n### Countries\n\n:cc​: (the Internet domain for the country, for example, ch, fr, etc...)\n"
  },
  {
    "path": "ui/src/main/resources/help/en/05.Startup arguments.md",
    "content": "# Startup arguments\n\nWhen running Xeres manually, you can provide the following command options. This is only for advanced usage and isn't normally needed.\n\n- `--no-gui`: start without a UI. Can be used to run Xeres in headless mode. Use another instance with `--remote-connect` to connect to it.\n- `--iconified`: start iconified into the tray. This is useful for auto startup.\n- `--data-dir=<path>`: specify the data directory. This is where Xeres stores all its user files. If you want to run several instances, they each need to have a different data directory.\n- `--control-address=<host>`: specify the address to bind to for incoming remote access (defaults to localhost only).\n- `--control-port=<port>`: specify the control port for remote access. This is the port the UI will connect to. If you want to run several instances, they each need to have a different control port, but Xeres will try to find a free slot automatically (starting from 1066) so this argument is rarely needed.\n- `--no-control-password`: do not protect the control address with a password. The password is auto-generated on the first run and is visible in the settings. It can be changed or disabled.\n- `--server-address=<host>`: specify a local address to bind to (if not specified, binds to all interfaces).\n- `--server-port=<port>`: specify the local port to bind to for incoming connections. By default, Xeres chooses a random port and uses it forever for the same instance.\n- `--fast-shutdown`: ignore proper shutdown procedure. This is mostly useful for testing when you need to quickly run/shutdown Xeres instances. Not needed for normal usage.\n- `--server-only`: only accepts incoming connections, do not make outgoing ones. Mostly useful for chat servers.\n- `--remote-connect:<host>[:<port>]`: starts as a UI client and connects to the specified node. You can also do this across machines on a LAN. Be wary that the connection is not encrypted. Use SSH tunnels if you want to overcome that limitation.\n- `--remote-password=<password>`: password to use when connecting remotely\n- `--version`: print the version of the software\n- `--help`: print the help message"
  },
  {
    "path": "ui/src/main/resources/help/en/06.Links.md",
    "content": "# Useful online links\n\n## Xeres\n\n- [Homepage](https://xeres.io)\n- [News](https://xeres.io/news)\n- [Documentation & FAQ](https://xeres.io/docs)\n- [Online discussions](https://github.com/zapek/Xeres/discussions)\n- [Roadmap](https://github.com/users/zapek/projects/4)\n- [Issues](https://github.com/zapek/Xeres/issues)\n- [Wiki](https://github.com/zapek/Xeres/wiki)\n- [GitHub Project page](https://github.com/zapek/Xeres)\n\n## Third parties\n\n- [ChatServer](https://retroshare.ch): an online server you can use if you have no friends to connect to. Created and maintained by the author of Xeres.\n- [Retroshare](https://retroshare.cc): the project that started it all. Xeres is compatible with it.\n- [Network Topology](https://retroshare.readthedocs.io/en/latest/concept/topology/): a good introduction about the network topology used by Retroshare and Xeres.\n"
  },
  {
    "path": "ui/src/main/resources/help/es/00.Index.md",
    "content": "Selecciona un tema de ayuda en la izquierda.\n\nEl botón de inicio te devolverá a esta página.\n\nLa [configuración rápida](01.Configuración%20rápida.md) es una lectura obligada para los usuarios primerizos.\n\nConsulta el tema [enlaces](06.Enlaces.md) para ver sitios en línea que puedes visitar para obtener más información.\n\nY recuerda que puedes posar el raton sobre la mayoría de los elementos de la interfaz y aparecerá una información sobre herramientas con una explicación después de un momento.\n"
  },
  {
    "path": "ui/src/main/resources/help/es/01.Configuración rápida.md",
    "content": "# Crear un perfil\n\nSi es la primera vez que ejecutas Xeres, necesitas crear un **perfil** y una **ubicación**.\n\nEl perfil básicamente eres tú (una persona) y la ubicación es tu máquina. Puedes tener varias ubicaciones, como una computadora de escritorio y una portátil, cada una ejecutando tu perfil.\n\nEs posible exportar un perfil desde tu primera máquina para usarlo en otra. Utiliza el menú `Herramientas / Exportar` para ello, luego impórtalo en la creación de la cuenta en tu otra máquina.\n\n# Agregar amigos\n\nAunque Xeres puede funcionar por sí solo, es mucho más interesante cuando comienzas a conectarte con algunos amigos.\n\nEl concepto principal para esto es un intercambio de ID. Tú das tu ID a tus amigos y tus amigos te dan su ID a ti. Solo si realizas este intercambio, pueden conectarse juntos.\n\n![](data:image/jpeg;base64,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)\n\nPuedes hacerlo directamente desde el panel **Inicio**. Presiona el siguiente botón a la derecha de tu ID para copiarlo al portapapeles.\n\n![Copy To Clipboard](data:image/jpeg;base64,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)\n\nLuego puedes pegar ese ID en el medio que prefieras para enviárselo a tu amigo, por ejemplo:\n\n- otra aplicación de chat\n- correo electrónico\n- SMS\n- un archivo de texto en una memoria USB\n\nUna vez que tu amigo te haya dado su ID, presiona el botón **Agregar Par**.\n\n![Add Peer](data:image/jpeg;base64,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)\n\nEsto abrirá la siguiente ventana donde puedes pegar el ID de tu amigo y presionar el botón **Agregar**.\n\n![Adding Friend](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAkACQAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAMcAlsDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD4bhht9Gsobq6hFzdTjdFC/wB1V/vN6/Som8V6l0jlSFf7qRrj9RR4qb/idSx9FjVEUeg2g/1qbwN4cTxd4t0zR5Lj7JHdyiNpsZKjrx78VNWpChSlWqfDFNv0WrNKNKeIqxo01eUmkvV6Ig/4SvVf+fr/AMhr/hR/wleq/wDP1/5DT/Cuo+OHw9074Ua7pdlBqhuV1CFpEjmAEiFSBzjjBzx9DXDaalve67pemzXcdo1/cx2yySH5U3uF3H2GcmubC4/D43CxxlF+403e3bfT5HXi8vxGBxcsFWVqiaVrrrtrt1NH/hKtU/5+v/Iaf4Uf8JVqn/P1/wCQ0/wrvfjV8ILL4YQaVLZ6jJdi7Lo8c4AYFQDuGO3P8q8soy/HYfM8PHFYZ3g720ts7dQzHL8RleJlhMUrTja+t91foa3/AAlWqf8AP1/5DT/Cj/hKtU/5+v8AyGn+FZNFejZHmamt/wAJVqn/AD9f+Q0/wo/4SrVP+fr/AMhp/hWTRQkgZrf8JVqn/P1/5DT/AAo/4SrVP+fr/wAhp/hWTRTshXZrf8JVqn/P1/5DT/Cj/hKtU/5+v/Iaf4Vk0UrIeprf8JVqn/P1/wCQ0/wo/wCEq1T/AJ+v/Ia/4Vk0U7IV2a3/AAlWqf8AP1/5DX/Cj/hKtU/5+v8AyGv+FZNFFkF2a3/CVap/z9f+Q1/wo/4SrVP+fr/yGv8AhWTRRZBdmt/wlWqf8/X/AJDX/Cj/AISnVP8An6/8hr/hWTRSsguzV/4SnVP+fr/yGv8AhR/wlOqf8/X/AJDX/CsqinZBdmr/AMJTqn/P1/5DX/Cj/hKdU/5+v/Ia/wCFZVFFkF2av/CU6p/z9f8AkNf8KP8AhKdU/wCfr/yGv+FZVFLlC7NX/hKdU/5+v/Ia/wCFH/CU6p/z9f8AkNf8KyqKdkF2av8AwlOqf8/X/kNf8KP+Ep1T/n6/8hr/AIVlUUrILs1f+Ep1T/n6/wDIa/4Uf8JTqn/P1/5DX/CsqinZBdmr/wAJTqn/AD9f+Q1/wo/4SnVP+fr/AMhr/hWVRRZBdmr/AMJTqn/P1/5DX/Cj/hKdU/5+v/Ia/wCFZVFFkF2av/CU6p/z9f8AkNf8KP8AhKdU/wCfr/yGv+FZVFFkF2av/CU6p/z9f+Q1/wAKP+Ep1T/n6/8AIa/4VlUUWQXZq/8ACU6p/wA/X/kNf8KP+Ep1T/n6/wDIa/4VlUUWQXZq/wDCU6p/z9f+Q1/wo/4SnVP+fr/yGv8AhWVRSaQXZq/8JTqn/P1/5DX/AAo/4SnVP+fr/wAhr/hWVRSsguzV/wCEp1T/AJ+v/Ia/4Uf8JTqn/P1/5DX/AArKoosguzV/4SnVP+fr/wAhr/hR/wAJTqn/AD9f+Q1/wrKoo0C7NX/hKdU/5+v/ACGv+FH/AAlOqf8AP1/5DX/CsqiiyC7NX/hKdU/5+v8AyGv+FL/wlWqf8/X/AJDX/CsmiiyC7Nb/AISrVP8An6/8hr/hR/wlWqf8/X/kNf8ACsmiiyC7Nb/hKtU/5+v/ACGv+FH/AAlWqf8AP1/5DX/CsmiiyC7Nb/hKtU/5+v8AyGv+FH/CVap/z9f+Q0/wrJr0LT/2dfivq9hb3tj8MfGV7ZXEayw3NvoF3JHKhGQysIyCCOQRRZBdnJ/8JVqn/P1/5DT/AAo/4SrVP+fr/wAhp/hXR+IfgR8S/COkXGq678PPFei6XbgGa+1DRLmCCME4G53QKOSBya4aiyC7Nb/hKtU/5+v/ACGn+FH/AAlWqf8AP1/5DT/CsmiiyC7Nb/hKtU/5+v8AyGn+FH/CVap/z9f+Q0/wrJoosguzW/4SrVP+fr/yGn+FH/CVap/z9f8AkNP8Kyasadp13rGoWthYWs19fXUqwW9rbRmSWaRiFVEUZLMSQABySaLILsvf8JVqn/P1/wCQ0/wo/wCEq1T/AJ+v/Iaf4VnXVrNY3M1tcwyW9xC5jkhlUq6MDgqwPIIIwQasWeiajqOn39/a6fdXNjp6o95dQws8VsruEQyMBhAzEKCcZJAHNFkF2Wf+Er1X/n6/8hr/AIUf8JXqv/P1/wCQ1/wrJoosguzdg1aDV3EGowxqz8LdRLtZT7+orMvbOWwupbeQHfGcZA4Poaq16PZWkV5ZW08qK0jxISSP9kVDRaZx3ir/AJD91/wH/wBAFZSO0bq6MVZTkMDgg+tavir/AJD91/wH/wBAFL4R8NT+MPElho9tIkM13JsEkn3V7k/kKmrUhRpyqVHaKV36Lc1pUp1qkaVJXlJpJeb2Oa8ZGXXbW4vb64lur1F3C5ncvIcdix56Vg+Gh/bN/NdXWJWhRFUNyM+v6frXp3x3+E2rfDi/0/S47qPUbTUUZ1u1QxlQpG4MmTjqO5zXDaH4Yvl8U6bZaZGsq6hNFZ4dsBXZgoYnsMnOfrXHhcXhsTho4rDyTpWunsrLy0tY7MXgsVhcVLC4mLVVOzW7u/PW97nQXup3mptG15dz3bRqEQzyM5VR0UZPA9qrV6J8Vvg3dfC2HTZpdRi1GG83KSkRjKOACRgk5HPXj6Csv4P6x4Z8O/FDwxq3jK3vbzwzp99Hd3trp0Mcs06RneIwsjKpDMqq2WHylsc4FVgcXhsbh418HJODvayts9dHbqLMMHisBiJYfGxaqK103fdXWqb6H1jqvhq0uPhRqv7OaWqHxJoHhKDxpgx5nGuruuru2G3/AFjfYp1iG3ODCc7scfOfhn4P6Inwtg8feN/FF14b0jUNRk03SLLS9LXUL6/eJVaeURvPAixR741L7ySzY2967HQP22/iVZ/G238Zah4v8S3Ph99bOoXXhptXnmszavMWe2WFnEZUIxRRgAYHAxVvxb8Qfhr44+HMfhnUbPxd4d8K6P4j1K68Ha5Y6RBcn7NcGOS5sZ4Xuo0MkRNuQ6TMcOMqu4Z7jzy6P2MNPvPiZpXhjTviNa3Gl6n4Ifxtb69PpTwwiEeaVjeMyFlBWMFn5K5I2EjnhdN+C/hLUfDviLxofGmq2vw80i4g06PUp9BiGo6hfy5PkW9p9s2ELGrSM7zrhR90nivfviz8WfC/wc+K/h23n03V7aC3+Dsfhc6W+yW/sbm4glMSXYYxhXCyxtIAARu4XtXivwP/AGh/+EB+GPiTwHeeIPFXg62v7+HWLHxF4MlP2yC5RRHJFLF58AlhkiPTzFKuiNhxwBXEy0P2ThJ4tg2+MIE8ASeFl8ZN4plsHDx6dv8ALZTbBz/pIlzH5IkIJwd+DWP4m+BXheP4EXPxP8KeOLzXLK21mHRZtK1PQ1sLqKV45JCzbbmZNu1FK7WbO452lcHtvCnx/gvvFPi7TtZ1T4j/ABI8Car4VfStW1PVJVuNUs18xJPtkUTSSJDGku1PLaZlIYsz5YIvQprfg/4Y/sf6HfaJZatr1pd/Ea01AN4ltYrA6slrA7TCKBHnUQofLiZ975eRsgYC07sRwtz+yhFqnwy8UeMfC2q+KNSstBs/7RF5rPhCXTdM1S2WQRyyWV4Z5BJtyHCSJGzLk4BVlHNfF34JeG/hL4S8J3Vx40u9U8T+I9AsPEFvo9tooWC3iuY0fbNcNccEZkC7I33bMsIwwr1DXP2kvhxL4k+NHiSG48b+Idb+IOhXmmWsmtQQINI82SN1t8i5k86H5QA4EZiWBFEbiQmPxz48fE/SviheeBZdKt7y3XQvCGleH7kXiIpe4todkjJtZsoT90nBI6gUajPVP2S/+Ex/4Uj8f/8AhAf7c/4S37Dov2L/AIRvzvt//IQXzPK8n5/9Xv3bf4d2eM1p/GnTF8YeGvg7onxS1q80/wCKiw6gutSLYf2jrn2YiN9NtrmFWWR7mTOI0mdWCy5cjIrxj4d/E/SvCPwa+LXhK8t7yTUvFttpkNjLAiGGM216s8nmksCAVUgbQ3PXA5q1+zd8XrD4O+LPEN5qL6rZxaz4fvdFj1fQ9pvtLlmCmO6hVnQMysgUqJIztdsMCMFiO61f9jDUdP8AFPwss/t+uaXo/jrU20gS+JvDj6ZqOnXCyhW8y0MzhkKOjoyy/N84IUrz5J8XfBXhv4feKbnQNC8TXfii70+eW11C5k0sWdssyMFIgbznaVchhuZY/ujAIOa918P/ALSHw18C6N8GNE0S08UXtn4H8Vvr2o319aW8Ut9G/lljHEs7BGypUIXIwoO8liB82+ONbg8S+NNf1e1SSO21DULi7iSYAOqSSMwDAEjOCM4JoV+oGJRRRQMKKKKokKKKKACiiipbGkFFFFUIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoooqQCiiikAUUUUAFFFFMCzbafLdxl0eBQDjEtxHGfyZgam/sW4/56Wn/AIGQ/wDxVUKKQEk8LW8rRuULDqY3Dj8wSDUdFFABRRRQAUUUUAFfSv7afxF8WaR+1N8RbKw8T6zZWcGohIre31CWOONRGmAqhgAPYV81V9U/HDTfhD8cPir4h8eQ/G3T/D6a9Mt5/Zd/4c1N57UmNQY3aOFkYggjKsQexoAwv2Z/HnibxDqHxLstV8Rarqdm/wAPfELNb3l7LLGSLJyCVZiMggEV8519M+B7f4WfBbTfHGr2nxdsvGOo6l4W1PQ7PSdO0HULeSSa6gMKsZJ4kRVXduOTnA4BNfM1ABRRRQAUUUUAFS2t1NY3MNzbTSW9xC4kjliYq6MDkMpHIIIyCKiq7odnaajrWn2t/fppVjPcRxXF/JE8q20bMA0pRAWYKCWwoJOMDmgD6j8DeBh+3erfMmg/FPSEhOq601s5stZsy6x+dN5anZeLkennY/vCvMfjJ8ULGLSR8NPA1ndaJ4E0q4JuBdp5V7rN4nytdXg6ggghIukY7ZzR8UPjJYxaTZ+BvhoLrQ/AmlXC3P2pj5V9rN4nS9uWXkEEZjjHEYx35p/xE+Inhn40+B38QeIJP7I+LGneVFPcwW5MHiWEkL5sm0YiuYxyznCyKP72AUM8aooopiCvTdI/5BNl/wBcE/8AQRXmVem6R/yCbL/rgn/oIqZFROI8Vf8AIfuv+A/+gCs+zvJ9Ouorm1mkt7iJg8csTFWUjoQR0rY8S2Mk2t3LqVAO3qf9kVmf2bL/AHk/M/4VXJzRs1dMpScXdOzRJrOv6n4iuEuNUv7nUJ0XYslzK0jBc5wCT05NUkdonV0Yo6nIZTgg+tWf7Nl/vJ+Z/wAKP7Nl/vJ+Z/wpQoqnFQhGyXRbFTqTqSc5ybb6vcsa14n1fxGYjqup3eo+TkR/apmk2Z64yeOgrMq3/Zsv95PzP+FH9my/3k/M/wCFFOjGlFQpxsl0WiCpVnVk51JNt9XqypXWeD/i546+HtjNZeFvGniHw1ZzSedLb6Pqs9pHJJgDeyxuAWwAMnnAFc9/Zsv95PzP+FH9my/3k/M/4VfK+xncjv7+51W+ub29uZby8uZGmnuLhy8ksjElnZjyzEkkk8kmoKt/2bL/AHk/M/4Uf2bL/eT8z/hRysLl3wr4y1/wLqo1Tw1rmpeHtTCNGL3SruS2mCHqu+Mg4OBkZp/i7xz4k+IGpx6j4o8Qap4k1COIQJd6veyXUqxgkhA8jEhQWY4zjLH1rP8A7Nl/vJ+Z/wAKP7Nl/vJ+Z/wo5X2C5Uoq3/Zsv95PzP8AhR/Zsv8AeT8z/hRysLlSirf9my/3k/M/4Uf2bL/eT8z/AIVSixMqUVb/ALNl/vJ+Z/wo/s2X+8n5n/ClysCpRVv+zZf7yfmf8KP7Nl/vJ+Z/wo5WMqUVb/s2X+8n5n/Cj+zZf7yfmf8ACqsySpRVv+zZf7yfmf8ACj+zZf7yfmf8KlpjRUoq3/Zsv95PzP8AhR/Zsv8AeT8z/hS5WO5Uoq3/AGbL/eT8z/hR/Zsv95PzP+FVyskqUVb/ALNl/vJ+Z/wo/s2X+8n5n/Ciz7AVKKt/2bL/AHk/M/4Uf2bL/eT8z/hRZgVKKt/2bL/eT8z/AIUf2bL/AHk/M/4U7MCpRVv+zZf7yfmf8KP7Nl/vJ+Z/woswKlFW/wCzZf7yfmf8KP7Nl/vJ+Z/woswKlFW/7Nl/vJ+Z/wAKP7Nl/vJ+Z/woswKlFW/7Nl/vJ+Z/wo/s2X+8n5n/AAoswKlFW/7Nl/vJ+Z/wo/s2X+8n5n/Cp5WBUoq3/Zsv95PzP+FH9my/3k/M/wCFLlYFSirf9my/3k/M/wCFH9my/wB5PzP+FPlYFSirf9my/wB5PzP+FH9my/3k/M/4U+VgVKKt/wBmy/3k/M/4Uf2bL/eT8z/hScWBUoq3/Zsv95PzP+FH9my/3k/M/wCFLlYFSirf9my/3k/M/wCFH9my/wB5PzP+FPlYFSirf9my/wB5PzP+FH9my/3k/M/4UuVgVKKt/wBmy/3k/M/4Uf2bL/eT8z/hRysCpRVv+zZf7yfmf8KP7Nl/vJ+Z/wAKfKwKlFW/7Nl/vJ+Z/wAKP7Nl/vJ+Z/wpcrAqUVb/ALNl/vJ+Z/wo/s2X+8n5n/CjlYFSirf9my/3k/M/4Uf2bL/eT8z/AIUcrAqUVb/s2X+8n5n/AAo/s2X+8n5n/CjlYFSirf8AZsv95PzP+FH9my/3k/M/4UcrAqUVb/s2X+8n5n/Cj+zZf7yfmf8ACjlYFSvTdI/5BNl/1wT/ANBFee/2bL/eT8z/AIV6HpalNMtFPUQoP/HRUSTW5UTndc/5Ck//AAH/ANBFUKv65/yFJ/w/9BFUK6o/CiXuFFFFUAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFfpf8UrfULD4u6t8FfhX8GvAOrrY+HUu/tWoWcUd1HEwWNpPNZlBcNKhBOWJOTnmolLlBI/NCivVfjJ+zF8QfgJpunX3jPSoNOttQlaC3aK8inLMoyRhGOOPWvRfgR+yT8XryTwr8RdB8GaP4k0hiL22tdVvYPIuV5G2SNnBxnPB9KHJWvcLHzLRX3V8S9Ssfin+zB8ZL3xD8OPCXhDxd4E1u006OTw5ZLC0cpu44ZgXBO4cuODtPB7A18K0RlzAwoooqwCu0sP+PG2/wCua/yri67PT/8Ajwtv+ua/yrCrsionNa3/AMhSb/gP/oIqjV7W/wDkJzf8B/8AQRVGtY/CiXuFFFFUAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFfT37CHjNdA+KPjG81XTde8QWlx4RvLW5GjRrNcwwma3LSne64VQDzk4JXivmGvqv9hz4YyXnxf8YaR4rsfE2kG18I3d5Jp+n3Nzpt5cKJrceWdhRnVwxGw/Kx28cCs525Xca3Por4UfEbwl4jtvhpH8OPB3jnxd4O8E3mpJeTXtlDcTF7mF2VSfMAYh5s84wuOuKpeBfiV4MttA+AqeJPDXj6PxDpb3h8PRaXbRC21KRpFMi4MmZAuIxj5cEmtv4b+DYvAlx8KtW+D2jax4Lk8U3Wqfa/DPjDU7xLZ/JhkUNPArMN2I9ynaTjZzXCx+Bv+Fd/8Iv4X/4Ti28ajW5ZYrvxTZXn2qP4elHDebZTZ/0QTGQgk+Xu8gddvHNo/wCvUo+NPjprVxqXxo+I04jvNPivfEeoTvYXXySRlrqRtkqAkB16EZOCOtcDXb/ErwlqMHjfx1c2k+oeKtI0zWrq3n8TMrTpcfv2VJ5Zxld0pw2S3zF+pzXEV2LYgKKKKYBXZWB/0G3/AOua/wAq42uxsP8Ajxt/+ua/yrCrsionOa3/AMhOb/gP/oIqjV7W/wDkJzf8B/8AQRVGtY/CiXuFFFFUAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFfot8VvFHhvxX8S9U+MXw8/aF0LwbqV7oKWn9mTQI126IquYWDt8rM8acbcgjuOv500VEo82oJnseoftgfGHVdY0nVbvxxeTahpTSvZTm3gBhMiFHIAjwcqSOc1h/CbxXrNzquoeDv+Exi8I+HvGMsdvrl7dRo0BRS7K0mcEAF2+6V+9XnFFPlXQLn2Z4ps/h78Df2S/iN4K0v4o6N491vxVfWElrDo6cxCGeKRy+GbA2o3JxzgV8Z0UURjygFFFFUAV2Nh/x42/8A1zX+VcdXY2H/AB42/wD1zX+VYVdkVE5zW/8AkJzf8B/9BFUava3/AMhOb/gP/oIqjWsfhRL3Nfwx4auvFWqrZWzJEAhllnlOEhjX7zsfQVvywfD/AE5/IebX9XdOGubVobeJj32qyscfWk8LO1t8NvGs8TFJHextiw6mNnkZl/Eov5VxVeneOHpQainKSvd621aslt0vcDtPP+Hn/Pj4n/8AAy3/APjVHn/Dz/nx8T/+Blv/APGq4uis/rT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/APAy3/8AjVHn/Dz/AJ8fE/8A4GW//wAari6KPrT/AJI/+AoDtPP+Hn/Pj4n/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1xddp8Gv+So+G/8Ar6H8jXF12z/3Wn/il+UA6BXY2H/Hjb/9c1/lXHV2Nh/x42//AFzX+VeXV2RUTnNb/wCQnN+H/oIqjV7Wv+QnN+H8hVGtY/CiXudp4d/5Jd4z/wCvrTv5z1xddp4d/wCSXeM/+vrTv5z1xdd+I/h0f8L/APS5AFWtS1S81m9kvNQu5767kxvuLmRpJHwABlmJJwAB9BVWiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK7Gw/wCPG3/65r/KuOrsbD/jxt/+ua/yry6uyKic5rX/ACE5vw/kKo1e1r/kJzfh/wCgiqNax+FEvc7Tw7/yS7xn/wBfWnfznri67Tw7/wAku8Z/9fWnfznri678R/Do/wCF/wDpcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/ANfQ/ka4uu0+DX/JUfDf/X0P5GuLrtn/ALrT/wAUvygHQK7Gw/48bf8A65r/ACrjq6+xP+hW/wD1zX+VeXV2RUTnta/5Cc3/AAH/ANBFUava1/yE5v8AgP8A6CKo1rH4US9ztPDv/JLvGf8A19ad/OeuLrtPDv8AyS7xn/19ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK6+x/wCPK3/65r/KuQrr7H/jyt/+ua/yry6uyKic9rX/ACE5v+A/+giqNXta/wCQnN/wH/0EVRrWPwol7naeHf8Akl3jP/r607+c9cXXaeHf+SXeM/8Ar607+c9cXXfiP4dH/C//AEuQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/6+h/I1xddp8Gv+So+G/wDr6H8jXF12z/3Wn/il+UA6BXX2P/Hlb/8AXNf5VyFdfY/8eVv/ANc1/lXl1dkVE57Wv+QnN/wH/wBBFUava1/yE5v+A/8AoIqjWsfhRL3O08O/8ku8Z/8AX1p38564uu08O/8AJLvGf/X1p38564uu/Efw6P8Ahf8A6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/wDX0P5GuLrtPg1/yVHw3/19D+Rri67Z/wC60/8AFL8oB0Cuvsf+PK3/AOua/wAq5Cuvsf8Ajyt/+ua/yry6uyKic9rX/ITm/wCA/wDoIqjV7Wv+QlN+H8hVGtY/CiXudp4d/wCSXeM/+vrTv5z1xddp4d/5Jd4z/wCvrTv5z1xdd+I/h0f8L/8AS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/r6H8jXF12nwa/5Kj4b/AOvofyNcXXbP/daf+KX5QDoFdfY/8eVv/wBc1/lXIV19j/x5W/8A1zX+VeXV2RUTndZ/5CU34fyFUqu6z/yEpvw/kKpVrH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0CuvsP+PK3/65r/KuQrr7D/jyt/8Armv8q8ursionO6z/AMhKb8P5CqVXdZ/5CU34fyFUq1j8KJe52nh3/kl3jP8A6+tO/nPXF12nh3/kl3jP/r607+c9cXXfiP4dH/C//S5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/AJKj4b/6+h/I1xddp8Gv+So+G/8Ar6H8jXF12z/3Wn/il+UA6BXX2H/Hlb/9c1/lXIV19h/x5W//AFzX+VeXV2RUTndY/wCQlN+H8hVKrusf8hKb8P5CqVax+FEvc7Tw7/yS7xn/ANfWnfznri67Tw7/AMku8Z/9fWnfznri678R/Do/4X/6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8AJUfDf/X0P5GuLrtPg1/yVHw3/wBfQ/ka4uu2f+60/wDFL8oB0Cutsv8Ajzg/65r/ACrkq62y/wCPOD/rmv8AKvLq7IqJz+sf8hKb8P5CqVXdY/5CU34fyFUq1j8KJe52nh3/AJJd4z/6+tO/nPXF12nh3/kl3jP/AK+tO/nPXF134j+HR/wv/wBLkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/+vofyNcXXafBr/kqPhv8A6+h/I1xdds/91p/4pflAOgV1tl/x5wf9c1/lXJV1tl/x5wf9c1/lXl1dkVE5/WP+QlN+H8hVKrusf8hKb8P5CqVax+FEvc7Tw7/yS7xn/wBfWnfznri67Tw7/wAku8Z/9fWnfznri678R/Do/wCF/wDpcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/ANfQ/ka4uu0+DX/JUfDf/X0P5GuLrtn/ALrT/wAUvygHQK62y/484P8Armv8q5Kutsv+POD/AK5r/KvLq7IqJz+sf8hKb8P5CqVXdY/5CU34fyFUq1j8KJe52nh3/kl3jP8A6+tO/nPXF12nh3/kl3jP/r607+c9cXXfiP4dH/C//S5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/AJKj4b/6+h/I1xddp8Gv+So+G/8Ar6H8jXF12z/3Wn/il+UA6BXW2X/HnB/1zX+VclXW2X/HnB/1zX+VeXV2RUTn9Y/5CU34fyFUqu6x/wAhKb8P5CqVax+FEvc7Tw7/AMku8Z/9fWnfznri67Tw7/yS7xn/ANfWnfznri678R/Do/4X/wClyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/AF9D+Rri67T4Nf8AJUfDf/X0P5GuLrtn/utP/FL8oB0Cutsv+POD/rmv8q5Kutsv+POD/rmv8q8ursionP6x/wAhKb8P5CqVXdY/5CU34fyFUq1j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXW2X/AB5wf9c1/lXJV1tl/wAecH/XNf5V5dXZFROf1j/kJTfh/IVSq7rH/IRm/D+QqlWsfhRL3O08O/8AJLvGf/X1p38564uu08O/8ku8Z/8AX1p38564uu/Efw6P+F/+lyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/X0P5GuLrtPg1/yVHw3/ANfQ/ka4uu2f+60/8UvygHQK62y/484P+ua/yrkq62y/484P+ua/yry6uyKic/rH/IRm/D+QqlV3WP8AkIzfh/IVSrWPwol7naeHf+SXeM/+vrTv5z1xddp4d/5Jd4z/AOvrTv5z1xdd+I/h0f8AC/8A0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/wCvofyNcXXafBr/AJKj4b/6+h/I1xdds/8Adaf+KX5QDoFdZY/8eUH/AFzX+VcnXWWP/HlB/wBc1/lXl1dkVEwNY/5CM34fyFUqu6x/yEZvw/kKpVrH4US9ztPDv/JLvGf/AF9ad/OeuLrtPDv/ACS7xn/19ad/OeuLrvxH8Oj/AIX/AOlyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf8A19D+Rri67T4Nf8lR8N/9fQ/ka4uu2f8AutP/ABS/KAdArrLH/jyg/wCua/yrk66yx/48oP8Armv8q8ursiomBrH/ACEZvw/kKpVc1f8A5CMv4fyFU61j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXWWRxZwf9c1/lXJ11Vl/x5wf9c1/lXl1dkVEwdX/5CMv4fyFU6uav/wAhGX8P5Cqdax+FEvc7Tw7/AMku8Z/9fWnfznri67Tw7/yS7xn/ANfWnfznri678R/Do/4X/wClyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/AF9D+Rri67T4Nf8AJUfDf/X0P5GuLrtn/utP/FL8oB0Cuqsv+POD/rmv8q5Wuqsv+POD/rmv8q8ursiomDq//IRl/D+QqnVzV/8AkIy/h/IVTrWPwol7naeHf+SXeM/+vrTv5z1xddp4d/5Jd4z/AOvrTv5z1xdd+I/h0f8AC/8A0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/wCvofyNcXXafBr/AJKj4b/6+h/I1xdds/8Adaf+KX5QDoFdVZf8ecH/AFzX+VcrXVWX/HnB/wBc1/lXl1dkVEwdX/5CMv4fyFU6uav/AMhGX8P5Cqdax+FEvc7Tw7/yS7xn/wBfWnfznri67Tw7/wAku8Z/9fWnfznri678R/Do/wCF/wDpcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/ANfQ/ka4uu0+DX/JUfDf/X0P5GuLrtn/ALrT/wAUvygHQK6qy/484P8Armv8q5Wuqsv+POD/AK5r/KvLq7IqJg6v/wAhGX8P5CqdXNX/AOQjL+H8hVOtY/CiXudp4d/5Jd4z/wCvrTv5z1xddp4d/wCSXeM/+vrTv5z1xdd+I/h0f8L/APS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv8A6+h/I1xddp8Gv+So+G/+vofyNcXXbP8A3Wn/AIpflAOgV1Vl/wAecH/XNf5VytdVZf8AHnB/1zX+VeXV2RUTB1f/AJCMv4fyFU6uav8A8hGX8P5Cqdax+FEvc7Tw7/yS7xn/ANfWnfznri67Tw7/AMku8Z/9fWnfznri678R/Do/4X/6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8AJUfDf/X0P5GuLrtPg1/yVHw3/wBfQ/ka4uu2f+60/wDFL8oB0Cuqsv8Ajzg/65r/ACrla6qy/wCPOD/rmv8AKvLq7IqJg6v/AMhGX8P5CqdXNX/5CMv4fyFU61j8KJe52nh3/kl3jP8A6+tO/nPXF12nh3/kl3jP/r607+c9cXXfiP4dH/C//S5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/AJKj4b/6+h/I1xddp8Gv+So+G/8Ar6H8jXF12z/3Wn/il+UA6BXVWX/HnB/1zX+VcrXVWX/HnB/1zX+VeXV2RUTB1f8A5CMv4fyFU6uav/yEZfw/kKp1rH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0Cuqsv+POD/rmv8q5Wuqsv+POD/rmv8q8ursiomDq/wDyEZfw/kKp1c1f/kIy/h/IVTrWPwol7naeHf8Akl3jP/r607+c9cXXaeHf+SXeM/8Ar607+c9cXXfiP4dH/C//AEuQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRR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)\n\nUna vez que se realiza este intercambio bidireccional de ID, Xeres se conectará de forma directa y segura, sin utilizar ningún servidor de terceros.\n\nNota: también hay un [servidor de chat](https://retroshare.ch/) en línea disponible, si solo quieres probar el software o estás buscando amigos.\n\nSugerencia: también puedes usar el botón del código QR, tomar una foto con tu smartphone y mostrársela a la otra instancia de Xeres usando el pequeño botón del escáner de códigos QR en la ventana de agregar pares."
  },
  {
    "path": "ui/src/main/resources/help/es/02.Red.md",
    "content": "# Conexiones\n\nLa conexión con otros amigos ocurre automáticamente siempre que los hayas agregado como se explica en la [configuración rápida](01.Configuración%20rápida.md).\n\n![](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAjwCPAAD/4QBmRXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAAExAAIAAAAQAAAATgAAAAAAAACPAAAAAQAAAI8AAAABUGFpbnQuTkVUIDUuMS45AP/bAEMAAgEBAQEBAgEBAQICAgICBAMCAgICBQQEAwQGBQYGBgUGBgYHCQgGBwkHBgYICwgJCgoKCgoGCAsMCwoMCQoKCv/bAEMBAgICAgICBQMDBQoHBgcKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCv/AABEIAd0CIQMBEgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP38ooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKjaZw+xISecZPA6e/9KdieZXs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)\n\nXeres se encarga automáticamente de encontrar a los pares y conectarse a ellos. Esto puede tomar un poco de tiempo, desde unos segundos hasta un par de minutos.\n\nRecuerda que ambos deben haber agregado el ID del otro; de lo contrario, no funcionará.\n\nEl número de amigos conectados se muestra también en la parte inferior izquierda de la ventana principal. Además, hay un indicador DHT y NAT cuya función se describe a continuación.\n\n## Asistentes\n\nPara mejorar las conexiones en una red dinámica y cambiante, las siguientes características pueden ser de ayuda.\n\n### DHT\n\nDHT significa Tabla Hash Distribuida y es un sistema que ayuda a dos pares a encontrarse cuando su dirección IP ha cambiado o es desconocida. Xeres utiliza la DHT de BitTorrent, también conocida como Mainline DHT. Si el LED no está en verde, conectar con amigos podría ser más difícil.\n\n### NAT\n\nSi estás detrás de un NAT (Traducción de Direcciones de Red: la mayoría de los routers están configurados con NAT), las conexiones entrantes podrían estar restringidas. Xeres intenta solucionar esto utilizando el protocolo UPNP. Asegúrate de que UPNP esté activado en tu router. Al contrario de la creencia popular, UPNP\nes seguro hoy en día, ya que todos los errores antiguos han sido solucionados. El LED NAT en Xeres debería estar en verde.\n"
  },
  {
    "path": "ui/src/main/resources/help/es/04.Emojis.md",
    "content": "# Emojis\n\nLos alias se pueden utilizar para mostrar rápidamente algunos emojis. Es preferible insertarlos directamente utilizando el atajo de teclado de tu sistema operativo (por ejemplo `Win`+`.` en Windows).\n\n### Los más comunes\n\n:joy​: :joy:\n\n:grin​: :grin:\n\n:rofl​: :rofl:\n\n:yum​: :yum:\n\n:blush​: :blush:\n\n:rage​: :rage:\n\n:scream​: :scream:\n\n:cry​: :cry:\n\n:sob​: :sob:\n\n:sick​: :sick:\n\n:poop​: :poop:\n\n:muscle​: :muscle:\n\n:wave​: :wave:\n\n:eyes​: :eyes:\n\n:zzz​: :zzz:\n\n:fire​: :fire:\n\n:heart​: :heart:\n\n:boom​: :boom:\n\n### Países\n\n:cc​: (el dominio de Internet del país, por ejemplo, ch, fr, etc.)"
  },
  {
    "path": "ui/src/main/resources/help/es/05.Argumentos de inicio.md",
    "content": "# Argumentos de inicio\n\nAl ejecutar Xeres manualmente, puedes proporcionar las siguientes opciones de comando. Esto es solo para uso avanzado y normalmente no es necesario.\n\n- `--no-gui`: inicia sin interfaz gráfica. Puede usarse para ejecutar Xeres en modo headless. Usa otra instancia con `--remote-connect` para conectarte a ella.\n- `--iconified`: inicia minimizado en la bandeja del sistema. Esto es útil para el inicio automático.\n- `--data-dir=<path>`: especifica el directorio de datos. Aquí es donde Xeres almacena todos sus archivos de usuario. Si quieres ejecutar varias instancias, cada una necesita tener un directorio de datos diferente.\n- `--control-address=<host>`: especifica la dirección a la que vincularse para el acceso remoto entrante (por defecto solo localhost).\n- `--control-port=<port>`: especifica el puerto de control para el acceso remoto. Este es el puerto al que se conectará la interfaz gráfica. Si quieres ejecutar varias instancias, cada una necesita tener un puerto de control diferente, pero Xeres intentará encontrar un puerto libre automáticamente (comenzando desde\n\t1066) por lo que este argumento rara vez es necesario.\n- `--no-control-password`: no protege la dirección de control con una contraseña. La contraseña se genera automáticamente en el primer inicio y es visible en la configuración. Puede ser cambiada o deshabilitada.\n- `--server-address=<host>`: especifica una dirección local a la que vincularse (si no se especifica, se vincula a todas las interfaces).\n- `--server-port=<port>`: especifica el puerto local al que vincularse para conexiones entrantes. Por defecto, Xeres elige un puerto aleatorio y lo usa permanentemente para la misma instancia.\n- `--fast-shutdown`: ignora el procedimiento de apagado correcto. Esto es principalmente útil para pruebas cuando necesitas ejecutar/apagar instancias de Xeres rápidamente. No es necesario para el uso normal.\n- `--server-only`: solo acepta conexiones entrantes, no realiza conexiones salientes. Principalmente útil para servidores de chat.\n- `--remote-connect:<host>[:<port>]`: inicia como cliente de interfaz gráfica y se conecta al nodo especificado. También puedes hacer esto entre máquinas en una LAN. Ten en cuenta que la conexión no está cifrada. Usa túneles SSH si quieres superar esa limitación.\n- `--remote-password=<password>`: contraseña para usar al conectarse remotamente.\n- `--version`: imprime la versión del software.\n- `--help`: imprime el mensaje de ayuda."
  },
  {
    "path": "ui/src/main/resources/help/es/06.Enlaces.md",
    "content": "# Enlaces útiles en línea\n\n## Xeres\n\n- [Página principal](https://xeres.io)\n- [Noticias](https://xeres.io/news)\n- [Documentación y Preguntas Frecuentes](https://xeres.io/docs)\n- [Discusiones en línea](https://github.com/zapek/Xeres/discussions)\n- [Hoja de ruta](https://github.com/users/zapek/projects/4)\n- [Incidencias](https://github.com/zapek/Xeres/issues)\n- [Wiki](https://github.com/zapek/Xeres/wiki)\n- [Página del proyecto en GitHub](https://github.com/zapek/Xeres)\n\n## Terceros\n\n- [ChatServer](https://retroshare.ch): un servidor en línea que puedes usar si no tienes amigos a los que conectarte. Creado y mantenido por el autor de Xeres.\n- [Retroshare](https://retroshare.cc): el proyecto que lo empezó todo. Xeres es compatible con él.\n- [Network Topology](https://retroshare.readthedocs.io/en/latest/concept/topology/): una buena introducción sobre la topología de red utilizada por Retroshare y Xeres.\n"
  },
  {
    "path": "ui/src/main/resources/help/fr/00.Index.md",
    "content": "Sélectionnez un sujet d'aide sur la gauche.\n\nLe bouton d'accueil vous ramènera à cette page.\n\nLe [guide de démarrage rapide](01.Configuration%20rapide.md) est indispensable pour les nouveaux utilisateurs.\n\nConsultez les [liens](06.Liens.md) pour découvrir des ressources en ligne où vous pourrez obtenir plus d'informations.\n\nEt n'oubliez pas que vous pouvez pointer votre souris sur la plupart des éléments de l'interface ; une infobulle avec une explication apparaîtra après un court instant.\n"
  },
  {
    "path": "ui/src/main/resources/help/fr/01.Configuration rapide.md",
    "content": "# Création d'un profil\n\nSi c'est la première fois que vous utilisez Xeres, vous devez créer un **profil** et un **emplacement**.\n\nLe profil, c'est essentiellement vous (une personne), et l'emplacement est votre machine. Vous pouvez avoir plusieurs emplacements, comme un ordinateur de bureau et un portable, chacun exécutant votre profil.\n\nIl est possible d'exporter un profil depuis votre première machine pour l'utiliser sur une autre. Utilisez pour cela le menu `Outils / Exporter`, puis importez-le lors de la création du compte sur votre autre machine.\n\n# Ajouter des amis\n\nBien que Xeres puisse fonctionner seul, il devient bien plus intéressant lorsque vous commencez à vous connecter avec des amis.\n\nLe concept principal pour cela est un échange d'identifiants (IDs). Vous donnez votre identifiant à vos amis, et vos amis vous donnent le leur. Ce n'est qu'après avoir effectué cet échange que vous pouvez être connectés.\n\n![Exchange](data:image/jpeg;base64,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)\n\nVous pouvez le faire directement depuis le panneau **Accueil**. Appuyez sur le bouton suivant à droite de votre identifiant pour le copier dans le presse-papiers.\n\n![Copy To Clipboard](data:image/jpeg;base64,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)\n\nVous pouvez ensuite coller cet identifiant dans le support de votre choix pour l'envoyer à votre ami, par exemple :\n\n- une autre application de messagerie\n- un email\n- un SMS\n- un fichier texte sur une clé USB\n\nUne fois que votre ami vous a remis son identifiant, appuyez sur le bouton **Ajouter un Pair**.\n\n![Add Peer](data:image/jpeg;base64,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)\n\nCela ouvrira la fenêtre suivante où vous pourrez coller l'identifiant de votre ami et appuyer sur le bouton **Ajouter**.\n\n![Adding Friend](data:image/jpeg;base64,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HxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCfHxP/AOBlv/8AGq4uij60/wCSP/gKA7Tz/h5/z4+J/wDwMt//AI1R5/w8/wCf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AAB+FVamwHo/xI8T6V4yvvDyLrEs6QxtDd301qVZcv8Af2KACMcgL2xnmptWg8Hz+DrLSovGHmS2LzTo39mTDzi+CF5+70xnJ615lRTsB1N9rVlN8ONM0tJs38N/LM8W1uEKgA5xj9a5aiimAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXY2H/AB42/wD1zX+VcdXY2H/Hjb/9c1/lXl1dkVE5zW/+QnN+H/oIqjV7Wv8AkJzfh/IVRrWPwol7naeHf+SXeM/+vrTv5z1xddp4d/5Jd4z/AOvrTv5z1xdd+I/h0f8AC/8A0uQBVrUtUvNZvZLzULue+u5Mb7i5kaSR8AAZZiScAAfQVVoriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/wDX0P5GuLrtPg1/yVHw3/19D+Rri67Z/wC60/8AFL8oB0CuxsP+PG3/AOua/wAq46uxsP8Ajxt/+ua/yry6uyKic5rX/ITm/D+QqjV7Wv8AkJzfh/6CKo1rH4US9ztPDv8AyS7xn/19ad/OeuLrtPDv/JLvGf8A19ad/OeuLrvxH8Oj/hf/AKXIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/8AX0P5GuLrtPg1/wAlR8N/9fQ/ka4uu2f+60/8UvygHQK7Gw/48bf/AK5r/KuOrr7E/wChW/8A1zX+VeXV2RUTnta/5Cc3/Af/AEEVRq9rX/ITm/4D/wCgiqNax+FEvc7Tw7/yS7xn/wBfWnfznri67Tw7/wAku8Z/9fWnfznri678R/Do/wCF/wDpcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/ANfQ/ka4uu0+DX/JUfDf/X0P5GuLrtn/ALrT/wAUvygHQK6+x/48rf8A65r/ACrkK6+x/wCPK3/65r/KvLq7IqJz2tf8hOb/AID/AOgiqNXta/5Cc3/Af/QRVGtY/CiXudp4d/5Jd4z/AOvrTv5z1xddp4d/5Jd4z/6+tO/nPXF134j+HR/wv/0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/wCSo+G/+vofyNcXXafBr/kqPhv/AK+h/I1xdds/91p/4pflAOgV19j/AMeVv/1zX+VchXX2P/Hlb/8AXNf5V5dXZFROe1r/AJCc3/Af/QRVGr2tf8hOb/gP/oIqjWsfhRL3O08O/wDJLvGf/X1p38564uu08O/8ku8Z/wDX1p38564uu/Efw6P+F/8ApcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdArr7H/jyt/8Armv8q5Cuvsf+PK3/AOua/wAq8ursionPa1/yE5v+A/8AoIqjV7Wv+QlN+H8hVGtY/CiXudp4d/5Jd4z/AOvrTv5z1xddp4d/5Jd4z/6+tO/nPXF134j+HR/wv/0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/wCSo+G/+vofyNcXXafBr/kqPhv/AK+h/I1xdds/91p/4pflAOgV19j/AMeVv/1zX+VchXX2P/Hlb/8AXNf5V5dXZFROd1n/AJCU34fyFUqu6z/yEpvw/kKpVrH4US9ztPDv/JLvGf8A19ad/OeuLrtPDv8AyS7xn/19ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK6+w/wCPK3/65r/KuQrr7D/jyt/+ua/yry6uyKic7rP/ACEpvw/kKpVd1n/kJTfh/IVSrWPwol7naeHf+SXeM/8Ar607+c9cXXaeHf8Akl3jP/r607+c9cXXfiP4dH/C/wD0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/AOvofyNcXXafBr/kqPhv/r6H8jXF12z/AN1p/wCKX5QDoFdfYf8AHlb/APXNf5VyFdfYf8eVv/1zX+VeXV2RUTndY/5CU34fyFUqu6x/yEpvw/kKpVrH4US9ztPDv/JLvGf/AF9ad/OeuLrtPDv/ACS7xn/19ad/OeuLrvxH8Oj/AIX/AOlyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf8A19D+Rri67T4Nf8lR8N/9fQ/ka4uu2f8AutP/ABS/KAdArrbL/jzg/wCua/yrkq62y/484P8Armv8q8ursionP6x/yEpvw/kKpVd1j/kJTfh/IVSrWPwol7naeHf+SXeM/wDr607+c9cXXaeHf+SXeM/+vrTv5z1xdd+I/h0f8L/9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv8AkqPhv/r6H8jXF12nwa/5Kj4b/wCvofyNcXXbP/daf+KX5QDoFdbZf8ecH/XNf5VyVdbZf8ecH/XNf5V5dXZFROf1j/kJTfh/IVSq7rH/ACEpvw/kKpVrH4US9ztPDv8AyS7xn/19ad/OeuLrtPDv/JLvGf8A19ad/OeuLrvxH8Oj/hf/AKXIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/8AX0P5GuLrtPg1/wAlR8N/9fQ/ka4uu2f+60/8UvygHQK62y/484P+ua/yrkq62y/484P+ua/yry6uyKic/rH/ACEpvw/kKpVd1j/kJTfh/IVSrWPwol7naeHf+SXeM/8Ar607+c9cXXaeHf8Akl3jP/r607+c9cXXfiP4dH/C/wD0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/AOvofyNcXXafBr/kqPhv/r6H8jXF12z/AN1p/wCKX5QDoFdbZf8AHnB/1zX+VclXW2X/AB5wf9c1/lXl1dkVE5/WP+QlN+H8hVKrusf8hKb8P5CqVax+FEvc7Tw7/wAku8Z/9fWnfznri67Tw7/yS7xn/wBfWnfznri678R/Do/4X/6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/9fQ/ka4uu0+DX/JUfDf8A19D+Rri67Z/7rT/xS/KAdArrbL/jzg/65r/KuSrrbL/jzg/65r/KvLq7IqJz+sf8hKb8P5CqVXdY/wCQlN+H8hVKtY/CiXudp4d/5Jd4z/6+tO/nPXF12nh3/kl3jP8A6+tO/nPXF134j+HR/wAL/wDS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/AK+h/I1xddp8Gv8AkqPhv/r6H8jXF12z/wB1p/4pflAOgV1tl/x5wf8AXNf5VyVdbZf8ecH/AFzX+VeXV2RUTn9Y/wCQlN+H8hVKrusf8hGb8P5CqVax+FEvc7Tw7/yS7xn/ANfWnfznri67Tw7/AMku8Z/9fWnfznri678R/Do/4X/6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8AJUfDf/X0P5GuLrtPg1/yVHw3/wBfQ/ka4uu2f+60/wDFL8oB0Cutsv8Ajzg/65r/ACrkq62y/wCPOD/rmv8AKvLq7IqJz+sf8hGb8P5CqVXdY/5CM34fyFUq1j8KJe52nh3/AJJd4z/6+tO/nPXF12nh3/kl3jP/AK+tO/nPXF134j+HR/wv/wBLkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/+vofyNcXXafBr/kqPhv8A6+h/I1xdds/91p/4pflAOgV1lj/x5Qf9c1/lXJ11lj/x5Qf9c1/lXl1dkVEwNY/5CM34fyFUqu6x/wAhGb8P5CqVax+FEvc7Tw7/AMku8Z/9fWnfznri67Tw7/yS7xn/ANfWnfznri678R/Do/4X/wClyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/AF9D+Rri67T4Nf8AJUfDf/X0P5GuLrtn/utP/FL8oB0Cussf+PKD/rmv8q5Oussf+PKD/rmv8q8ursiomBrH/IRm/D+QqlVzV/8AkIy/h/IVTrWPwol7naeHf+SXeM/+vrTv5z1xddp4d/5Jd4z/AOvrTv5z1xdd+I/h0f8AC/8A0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/wCvofyNcXXafBr/AJKj4b/6+h/I1xdds/8Adaf+KX5QDoFdZZHFnB/1zX+VcnXVWX/HnB/1zX+VeXV2RUTB1f8A5CMv4fyFU6uav/yEZfw/kKp1rH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0Cuqsv+POD/rmv8q5Wuqsv+POD/rmv8q8ursiomDq/wDyEZfw/kKp1c1f/kIy/h/IVTrWPwol7naeHf8Akl3jP/r607+c9cXXaeHf+SXeM/8Ar607+c9cXXfiP4dH/C//AEuQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/6+h/I1xddp8Gv+So+G/wDr6H8jXF12z/3Wn/il+UA6BXVWX/HnB/1zX+VcrXVWX/HnB/1zX+VeXV2RUTB1f/kIy/h/IVTq5q//ACEZfw/kKp1rH4US9ztPDv8AyS7xn/19ad/OeuLrtPDv/JLvGf8A19ad/OeuLrvxH8Oj/hf/AKXIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/8AX0P5GuLrtPg1/wAlR8N/9fQ/ka4uu2f+60/8UvygHQK6qy/484P+ua/yrla6qy/484P+ua/yry6uyKiYOr/8hGX8P5CqdXNX/wCQjL+H8hVOtY/CiXudp4d/5Jd4z/6+tO/nPXF12nh3/kl3jP8A6+tO/nPXF134j+HR/wAL/wDS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/AK+h/I1xddp8Gv8AkqPhv/r6H8jXF12z/wB1p/4pflAOgV1Vl/x5wf8AXNf5VytdVZf8ecH/AFzX+VeXV2RUTB1f/kIy/h/IVTq5q/8AyEZfw/kKp1rH4US9ztPDv/JLvGf/AF9ad/OeuLrtPDv/ACS7xn/19ad/OeuLrvxH8Oj/AIX/AOlyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf8A19D+Rri67T4Nf8lR8N/9fQ/ka4uu2f8AutP/ABS/KAdArqrL/jzg/wCua/yrla6qy/484P8Armv8q8ursiomDq//ACEZfw/kKp1c1f8A5CMv4fyFU61j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXVWX/AB5wf9c1/lXK11Vl/wAecH/XNf5V5dXZFRMHV/8AkIy/h/IVTq5q/wDyEZfw/kKp1rH4US9ztPDv/JLvGf8A19ad/OeuLrtPDv8AyS7xn/19ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK6qy/wCPOD/rmv8AKuVrqrL/AI84P+ua/wAq8ursiomDq/8AyEZfw/kKp1c1f/kIy/h/IVTrWPwol7naeHf+SXeM/wDr607+c9cXXaeHf+SXeM/+vrTv5z1xdd+I/h0f8L/9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv8AkqPhv/r6H8jXF12nwa/5Kj4b/wCvofyNcXXbP/daf+KX5QDoFdVZf8ecH/XNf5VytdVZf8ecH/XNf5V5dXZFRMHV/wDkIy/h/IVTq5q//IRl/D+QqnWsfhRL3O08O/8AJLvGf/X1p38564uu08O/8ku8Z/8AX1p38564uu/Efw6P+F/+lyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/X0P5GuLrtPg1/yVHw3/ANfQ/ka4uu2f+60/8UvygHQK6qy/484P+ua/yrla6qy/484P+ua/yry6uyKiYOr/APIRl/D+QqnVzV/+QjL+H8hVOtY/CiXudp4d/wCSXeM/+vrTv5z1xddp4d/5Jd4z/wCvrTv5z1xdd+I/h0f8L/8AS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/r6H8jXF12nwa/5Kj4b/AOvofyNcXXbP/daf+KX5QDoFdVZf8ecH/XNf5VytdVZf8ecH/XNf5V5dXZFRMHV/+QhN+H8hVOrmr/8AIRl/D+QqnWsfhRL3O08O/wDJLvGf/X1p38564uu18LI1z8NvGsESl5EexuSo6iNXkVm/Auv51xVd+I/h0f8AC/8A0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/wCvofyNcXXa/BpG/wCFk6NOFJjtXa5lbskaIzMx/AVxVd0/91p/4pflAOgV1Vl/x5wf9c1/lXK11Vl/x5wf9c1/lXlVdkVEwdX/AOQjL+H8hVOrmr/8hGX8P5Cqdax+FEvc1/DHiW68K6qt7bKkoKGKWCUZSaNvvIw9DW/LP8P9Rfz3h1/SHflra1WG4iU99rMynH1riaK7KeIlCPI0pLs1+XUDtPI+Hn/P94n/APAO3/8AjtHkfDz/AJ/vE/8A4B2//wAdri6Kv6yv+fcfuf8AmB2nkfDz/n+8T/8AgHb/APx2jyPh5/z/AHif/wAA7f8A+O1xdFH1lf8APuP3P/MDtPI+Hn/P94n/APAO3/8AjtHkfDz/AJ/vE/8A4B2//wAdri6KPrK/59x+5/5gdp5Hw8/5/vE//gHb/wDx2jyPh5/z/eJ//AO3/wDjtcXRR9ZX/PuP3P8AzA7TyPh5/wA/3if/AMA7f/47R5Hw8/5/vE//AIB2/wD8dri6KPrK/wCfcfuf+YHaeR8PP+f7xP8A+Adv/wDHaPI+Hn/P94n/APAO3/8AjtcXRR9ZX/PuP3P/ADA7TyPh5/z/AHif/wAA7f8A+O0eR8PP+f7xP/4B2/8A8dri6KPrK/59x+5/5gdp5Hw8/wCf7xP/AOAdv/8AHaPI+Hn/AD/eJ/8AwDt//jtcXRR9ZX/PuP3P/MDtPI+Hn/P94n/8A7f/AOO0eR8PP+f7xP8A+Adv/wDHa4uij6yv+fcfuf8AmB2nkfDz/n+8T/8AgHb/APx2jyPh5/z/AHif/wAA7f8A+O1xdFH1lf8APuP3P/MDtPI+Hn/P94n/APAO3/8AjtHkfDz/AJ/vE/8A4B2//wAdri6KPrK/59x+5/5gdp5Hw8/5/vE//gHb/wDx2jyPh5/z/eJ//AO3/wDjtcXRR9ZX/PuP3P8AzA7TyPh5/wA/3if/AMA7f/47R5Hw8/5/vE//AIB2/wD8dri6KPrK/wCfcfuf+YHaeR8PP+f7xP8A+Adv/wDHaPI+Hn/P94n/APAO3/8AjtcXRR9ZX/PuP3P/ADA7TyPh5/z/AHif/wAA7f8A+O0eR8PP+f7xP/4B2/8A8dri6KPrK/59x+5/5gdp5Hw8/wCf7xP/AOAdv/8AHaPI+Hn/AD/eJ/8AwDt//jtcXRR9ZX/PuP3P/MDtPI+Hn/P94n/8A7f/AOO0eR8PP+f7xP8A+Adv/wDHa4uij6yv+fcfuf8AmB2nkfDz/n+8T/8AgHb/APx2jyPh5/z/AHif/wAA7f8A+O1xdFH1lf8APuP3P/MDtPI+Hn/P94n/APAO3/8AjtHkfDz/AJ/vE/8A4B2//wAdri6KPrK/59x+5/5gdp5Hw8/5/vE//gHb/wDx2jyPh5/z/eJ//AO3/wDjtcXRR9ZX/PuP3P8AzA7TyPh5/wA/3if/AMA7f/47R5Hw8/5/vE//AIB2/wD8dri6KPrK/wCfcfuf+YHaeR8PP+f7xP8A+Adv/wDHaPI+Hn/P94n/APAO3/8AjtcXRR9ZX/PuP3P/ADA7TyPh5/z/AHif/wAA7f8A+O0eR8PP+f7xP/4B2/8A8dri6KPrK/59x+5/5gdp5Hw8/wCf7xP/AOAdv/8AHaPI+Hn/AD/eJ/8AwDt//jtcXRR9ZX/PuP3P/MDtPI+Hn/P94n/8A7f/AOO0eR8PP+f7xP8A+Adv/wDHa4uij6yv+fcfuf8AmB2nkfDz/n+8T/8AgHb/APx2jyPh5/z/AHif/wAA7f8A+O1xdFH1lf8APuP3P/MDtPI+Hn/P94n/APAO3/8AjtHkfDz/AJ/vE/8A4B2//wAdri6KPrK/59x+5/5gdp5Hw8/5/vE//gHb/wDx2jyPh5/z/eJ//AO3/wDjtcXRR9ZX/PuP3P8AzA7TyPh5/wA/3if/AMA7f/47R5Hw8/5/vE//AIB2/wD8dri6KPrK/wCfcfuf+YHaeR8PP+f7xP8A+Adv/wDHaPI+Hn/P94n/APAO3/8AjtcXRR9ZX/PuP3P/ADA7TyPh5/z/AHif/wAA7f8A+O0eR8PP+f7xP/4B2/8A8dri6KPrK/59x+5/5gdp5Hw8/wCf7xP/AOAdv/8AHaPI+Hn/AD/eJ/8AwDt//jtcXRR9ZX/PuP3P/MDtPI+Hn/P94n/8A7f/AOO0eR8PP+f7xP8A+Adv/wDHa4uij6yv+fcfuf8AmB2nkfDz/n+8T/8AgHb/APx2jyPh5/z++Jv/AADt/wD47XF0UfWV/wA+4/c/8wOz1DxhpWlaPc6X4XsLi2S7Ty7vUL51a4lTugC/KinjOM5rjKKKwq1pVmubpslokAV1Vl/x5wf9c1/lXK11Vl/x5wf9c1/lXFV2RUTB1f8A5CMv4fyFU6uav/yEJvw/kKp1rH4US9woooqgCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAK6qy/484P+ua/yrla6qy/484P+ua/yrCrsiomDq//ACEJvw/kKp1c1f8A5CMv4fyFU61j8KJe5d0jSptYvBbxFUwC7yOcKijqx9q03j8NWreWz6lesvBlhKRIT7AgnFJo7GLwrr8iHa7NbxEj+6Wckf8AjorArtvGlCLSTb119Wv0A3vM8Mf8++rf9/4v/iKPM8Mf8++rf9/4v/iKwaKn27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN7zPDH/Pvq3/f+L/4ijzPDH/Pvq3/AH/i/wDiKwaKPbv+VfcgN7zPDH/Pvq3/AH/i/wDiKPM8Mf8APvq3/f8Ai/8AiKwaKPbv+VfcgN7zPDH/AD76t/3/AIv/AIijzPDH/Pvq3/f+L/4isGij27/lX3IDe8zwx/z76t/3/i/+Io8zwx/z76t/3/i/+IrBoo9u/wCVfcgN650OzvLGW80i4llWBd01tcKBKi/3gRwwrBrf8Csf+EqsI84SZjE49VZSCKwKKijKEaiVr3X3W/zAK6qy/wCPOD/rmv8AKuVrqrL/AI84P+ua/wAq8+rsiomDq/8AyEZfw/kKp1c1f/kIy/h/IVTrWPwol7m9pf8AyKGu/wDXa1/nJWDW9pf/ACKGu/8AXa1/nJWDXXV+Cn6f+3SAKKKK5gCivWP2ef2d9W/aM1vW9I0XWNM03UdPsGvIba/l2veOOFjjXrjP3m6LkZ615z4k8N6p4P16+0XWrGbTNVsZTDc2lwu143HUEf16EEEcUrq9gM2iiimAUV7l8Pv2TNZ8efDbTvHE/jnwN4R0S/uZbS3PijV3sneSMkMBmIqehOAxOKXxv+x3448K+ELjxXo194e+Ifhm0Dm91TwXqY1CKzK4LCQbVbhSGJVWCryxAqJTjF2bCK5tjwyijrXonxQ+FFt8K9I8PQalrhm8ZX9st7qHh+O0wNKicbollm3/AOuZSrGPYNobk9M03ZXYLV2PO6K3/wDhBNe/4Qb/AITE6c48NfbxpYvy6hTc+WZPLC53H5RnIGB0zmsCn1sHS4UUo5PpXsetfAbRIPjf4R8A6B8QdN8V2WvGzR9d0uESRWsk77WQoJDuZOCRuB5GdpyA0rtJdXYlyUU2+iueN0V0HxB8Kf8ACCeO/EXhv7V9u/sjULiw+0+X5fm+VIyb9uTtztzjJxnqa5+ojJTipR2ZcouLcXugoooqhBRXo3x1+EH/AApXxVpmjf2t/bP23SLTVfP+zeRs89N3l7d7Z29N2Rn0Fec0k738rr7tGHRPvZ/fqFFFFMAooooAKKK77xx8OdD8K/D/AMFeINO8bab4g1PXoZpL7RLRQJ9JZGAVZfnJy2T95U6HG4c0m7K79P6+4Fq7L1OBooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQBveBP+Rv0r/rsP5GsGt7wJ/yN+lf9dh/I1g10y/gR9ZflEArqrL/jzg/65r/KuVrqrL/jzg/65r/KvPq7IqJg6v8A8hGX8P5CqdXNX/5CMv4fyFU61j8KJe5vaX/yKGu/9drX+clYNb2l/wDIoa7/ANdrX+clYNddX4Kfp/7dIAooormA1PC/ijVvBXiGw1zQr+bTNXsZRNbXdu2HjcfzHYg8EEg5Br74/am8L6T8Uv2XNA+KXxMsIfh98UxAsVtEi5k1YfwRtF95dy/OM8xZ5OOK+X/2UvHHwz+G3jy88S/EXSr3WZNMtTc6LawIrwveKcqJFPfptY/Kp5I6Ecz8d/jv4m/aC8cz+I/Ec+FGY7LT4mPkWUOeI0H6lurHk9gMpJykrdBnnNFFFaiPpfx9/wAmEfC//sZ9Q/k9c1+xZ431TwZ+0d4OjsJpfsmsXqaVf2ikmO5gmOwq69GAJDDPQqDXpnh3wlo3xh/Y78DeFYPiN4G8Ka3peuXt7cWvijXEs38tiwXCgM3OQeQBjvVHwVpvw4/ZEvW8X6l420T4m/Ei2hc6JovhZ2u9MtJXV1W5nuiArbcH92AGUlTg5DLCnGnUm5K6007+6lb57fnoQ4upSUY76/L3nZ/Lf8tR3g39mTRfEvxg+MWo3+la1qvg7wNrE0Ufh/wzbNLe6i7XEiw20YUEogCfOw5Cjgj7w1PGHwI0D4jfC3xlr+nfBjxb8FfEHhezfVQdVnvLuy1WAEeajSXSKVlUAsoTqN2c/wAPN/stfGWyey+I3g/xJ461DwBqfjSWC8s/GFpM8P2a+SRmPnOhUqknmfM2VUANkjOab8U7Dxx4S8BaxP4g/af03xdHcRC2h0Dw94tvNXe/LsFeOVOFjj8syMWfIJULjLCueUZQioN7JJPztr669H0tprc6FJSqOS6yenl09Fbr0d9dD0e1+JPwrtP2LtJ1C9+Dn2/Q08V/Y5NG/wCEouo/MvRZ5a884LuG4ZHlY2jOc14B8F7v4YxR3ba98Pte+JHiy/vTbaV4V06/ltrdYSFbeZIlMzyg7lVV3AgNuAO013nwp0/w78Xf2Ur34eP458N+DPE+m+KRrSf8JVfiytriB7fysJIQdzA54AOMDOMg11vwX8RLc/s2L4O8AfFnwt8K/GUOrTt4huNYv/7ObU4GJ8iSC82FvlCAYjwcE5Khhv2lpOpLr7v3NRv+N13S0Wi0xj8EI+t/LWVvv09W02cL+1F8EtB8K/DzwV8QfD/g7WvhwNbmmsL/AMJa280j2c0fKujzASMrgE5YemMdK39U+Hnh/wCGn7Y/wa03w3p/9m2VymhX8sXnSS7p5WUyPl2YjJ7A4HYCpP2l/Gujax+y94A0OP4lj4keI7DXbptQvri8aW4YlGyVSVjN5IJ2I7hd23IABAqT4ieOPDl9+2D8HtZtvEGl3Gj2NnoK3eoRXsbW9uY8eYJJA21Cv8WSMd6dBtVI66c6+5xb662v38iaqvTlffkl96kkvnb9fMg8G/B/w/8AFT9pD443mvWN/wCIx4budS1a38K6VL5VzrDrdMPJDAFgnQNsG/51288Hx74z+KPh1r1pa2nhP4YXvwz1+wu5Yb+2m1ifUFlTAG1/PAaORHVhtC9zk5AA7G38Kw/Ej9oP4k6roHxS8P8AgLVLTWrq+0nVtT1U2NvdI9xIGaG7QnawVlI2g7gxxgAmun/at8b6fqfwo8IeHfEnjDw58Svinp92xm8S+HMTxxaaEYR28l0FUTOXbd0JGGLcnc/NTuqVL0jp+v8Amn0Xc65616nq9flt/k11aPm/wH4PvfiF410LwzpxVb3V72KyiZ/uqzuF3H2Gcn6V9E+Pbn9nX4X+J734dS/D/XfE0mmytp2peORrslvdpcB2WWSC0AMLiM8KGwG24OfvN4L8JPHC/DP4n+FfFb25u49H1KC9eAHBkRHBZQfUjOPfFfQXxW+Bfw48W+N9X+IWlfGnwja+BdWuH1WexmuXfXIPNctJDHZBdzsGOF3FeDk8DJ65292/w63/AAttquu3+RzR1cu+lvxv5Ppv/mdD+0X4R0XxL+2f8L/DV6f7Z0C80/Q7GQgtD9qt2O3PykMu5eeDkZ6187eL/CWk6X+0Pq/hi1tPK0ODxPJp0dr5jnbALoxhNxO4/LxknPvmvav2ifiv4X0z9q74feMvD13Bq/h7RrHRpitncpcMiQnc0LMjEeYqjBGeD1ra8YfCH4bXXxwk+KJ+MvhGbwJf6vHrf9nWt6X1smWQS+R9lx8nzttLuy7FyzAbSKVF2nCUtuad/wDwJWv662+dhVL+zlFb8sLfKLvb8P6R4b+1b4G0T4a/tB+MfDXhuy/s7RNPuIktrXzXl8sGCNiNzszHlieSetbn7IHwv8MfFnx34m0rxXHH/Z9t4avr6K5llljS0mTZtnPlspYJuJ2nIPcGqX7ZWv6Z4o/aY8c6po2o2mr6Zc3MLQ3tjOs0MoFvECVdSQeQRweoNdl+wP8A2efiT42/tYzDSv8AhDNT+1m2AMohxHv2Z43bc4z3rGi5fVrvfke/flf6m9WyrWW3Mtu3MjS+GDfs7fFHxVY/DZPh9r2izao/2DTvG8uuvLePcFh5Uk1pgQIG6ELuAyB33Dy7wjoXgb4ZfEfxbpfxP0rU/E0mgyzWVno2k3Btk1C8SUx7ZZcb0iwCcoQ2duAeRXrHwn+Dvw8+EPjXTfiX4j+MHg7X/CmiSDU9P0zRbl5NYvpVYG2SSzZA0BLbWYEttK4OAS6637O3xftfEJ+Mt9p/jDQfhl8UvFN8t/puu+IGRLcWzzPJPbCdkKRHkc7dzHaQCVyt3s+aN3ZO/rdJej3bstElpsZ9LPa6t+N9d2tkr9Xvu1m/GD4H+GdX/Z+1n4g6V8KvEXwY1nw7fwW8+k6vPdXEGo287KiyI9yqtuVj/CMAdc7gV8y+LHw88P8Ahr4C/BjxHpun/ZtZ8Qw6k2p3PnSP9oMVwqR/KzFVwpI+UDPfNe3eMvFVtpH7KXxT8J+IvjVbfEzxlJeaY6xrrEl7boouVbZZyTMHnwg3yNGu1cqMkq1eRfGbxLpGqfs4fAfTLLVbK71LTYdVF7ZwXCPNal7lWQSoDlNw5G4DI6UQ3stuePfZwu9+l/l+ARd2ubtL8Hod9+0r4W+C3wD8R6r4ctfAN1r+tarpMM9o51u4t7fQ2eABJFGXaeRpN8jK5CBRGF6tXyNXvP7bXiTSPFfx8v8AUNE1Sy1iwbTbBFutPuEniLLbIGAdCRkEEEZ4Irwaildpyfn+bt/XUNoxXkvvsrhRRRWwgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA3vAn/I36V/12H8jWDW94E/5G/Sv+uw/kawa6ZfwI+svyiAV1Vl/wAecH/XNf5VytdVZf8AHnB/1zX+VefV2RUTB1f/AJCMv4fyFU6uav8A8hGX8P5Cqdax+FEvc3tL/wCRQ13/AK7Wv85Kwa3tL/5FHXR3861P6yVg111fgp+n/t0gCiiiuYAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAr1f8AZ2+LGkfCPXfFl7rFve3MWreGr7RoBYojss0yqEZtzLhBg5IyfQGvKKKTXNFxfVNferAtGn2af3O4UUUUwCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDe8Cf8jfpX/XYfyNYNb3gXjxdpZ7CYE/kawa6ZfwI+svyiAV1Vl/x5wf8AXNf5VytdVZf8ecH/AFzX+VefV2RUTB1f/kIy/h/IVTq5q/8AyEZfw/kKp1rH4US9zW8P6rFp8txBdo0ljdx+VMq/eHOQw9weatP4VjmbdZ6xpssB5UzXAhcD0KtjBrn6K6o1VyqE43S27gb3/CITf9BPSf8AwPj/AMaP+EQm/wCgnpP/AIHx/wCNYNFPno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijno/yfj/wAN7/hEJv+gnpP/gfH/jR/wiE3/QT0n/wPj/xrBoo56P8AJ+P/AAAN7/hEJv8AoJ6T/wCB8f8AjR/wiE3/AEE9J/8AA+P/ABrBoo56P8n4/wDAA3v+EQm/6Cek/wDgfH/jR/wiE3/QT0n/AMD4/wDGsGijno/yfj/wAN7/AIRCb/oJ6T/4Hx/40f8ACITf9BPSf/A+P/GsGijnpfyfj/wAOmhaz8KQyyR3kV/qskZjjNv80cAYYLbu7Yz0rmaKKipU57JKyXQArqrL/jzg/wCua/yrla6qy/484P8Armv8q4quyKiYOr/8hGX8P5CqdXNX/wCQjL+H8hVOtY/CiXuFFFFUAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAGloehz67dmGEhFUZeQ9FFdlH8OLAKN9zcs3cqVA/kaPhxGo0i5fHzGcqT7BR/ia6yvo8JhKTpKc1dsaRyv/CudN/573X/AH2v/wATR/wrnTf+e91/32v/AMTXVUV2/VKH8iHY5X/hXOm/897r/vtf/iaP+Fc6b/z3uv8Avtf/AImuqoo+qUP5EFjlf+Fc6b/z3uv++1/+Jo/4Vzpv/Pe6/wC+1/8Aia6qij6pQ/kQWOV/4Vzpv/Pe6/77X/4mj/hXOm/897r/AL7X/wCJrqqKPqlD+RBY5X/hXOm/897r/vtf/iaP+Fc6b/z3uv8Avtf/AImuqoo+qUP5EFjlT8ONOxxPdZ/3l/8Aia5vxL4Ql0NPPjk8+1JwWIwVPvXp1Z/iGNZNC1AMMgQO34gZH8qwrYKi6b5VZiseP1qaBoEuu3EirIsFvEu6ad+Qg7cdyfSsuu28ODy/BrleDLfMre4WNSP/AEI187RjGTblslcQDw54ej+UvqUxH8avGgP4FT/Ol/4R/wAO/wB3VP8Av9H/APEUtFV7d/yr7gE/4R/w7/d1T/v9H/8AEUf8I/4d/u6p/wB/o/8A4iloo9u/5V9yAT/hH/Dv93VP+/0f/wARR/wj/h3+7qn/AH+j/wDiKWij27/lX3IBP+Ef8O/3dU/7/R//ABFH/CP+Hf7uqf8Af6P/AOIpaKPbv+VfcgE/4R/w7/d1T/v9H/8AEUf8I/4d/u6p/wB/o/8A4iloo9u/5V9yAT/hH/Dv93VP+/0f/wARR/wj/h3+7qn/AH+j/wDiKWij27/lX3IDP1fwjAtlLeaXcSTxwjdLBOoEiD+8COCK5evS/DY36zbxn7kpMbj1UggivNKKijKEaiVr3X3W/wAwCuqsv+POD/rmv8q5Wuqsv+POD/rmv8q8+rsiomDq/wDyEZfw/kKp1c1f/kIy/h/IVTrWPwol7hRRRVAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB6N8OP+QJP/ANfDf+grXVVyvw4/5Ak//Xw3/oK11VfYYT+BD0KQVHNdQ2xjEs0cRkbYgdgNzegz1NSVy+r2jeIdSvoo2/48INsTDtcNhgfqAq/99VrVm4LRXf8AT/rzGdDfXsOm2klzcP5cEYyzYJwPoOamVgyhhyCMisO78ROvhFtXt1UyiIOEkBwGyAQcHsc1NcarcTahFp9mIlnMInlmlUssak4A2ggkk+4xQ6iUrb7fjf8AyF0ua9FY0Wr3MF1d2V2IWuooDcRyRAqki8j7pJIII9T1pkmu3CeDxqwSP7R9mE23B2ZI9M5x+NL20bN9lf8Ar7h9bG5RXK6sL2XxXopingj3QzFN8LMF+Vc5+cZz26Y9605dSu7vVJbCyMKNborTzzIXUFuihQw575zx70Krq011t6iua9Fczc+Kbi20We4aGP7Za3S200a5Kk7gCV5B5ByM/rWzpTag8UjailvG5fMaW5Y7V7Biep+nFONWM3aIF2qOvf8AID1D/r3k/wDQTV6qOvf8gPUP+veT/wBBNVU+B+gHjtdv4f8A+RLX/sISf+i0riK7fw//AMiWv/YQk/8ARaV8jR2n6fqiRaKKfFE88qRxqXkchVVRkknoBXOk27IWxefw5qyaCmuNpd6uivObVNSNu4tmmAyYxJjaWxztznFZ1fXd1ZR3dxqX7PkfllrLwyhgXO4HX4s3spU88sHlg+igV4z8H/hp4b8Y+D/iFrviS/1Gwi8M2dtdxCw2EzF5thjIYdW4VTkbSwJDAbTkpq77br01V/nZteVirOy7tpfN2/zs/NM8qor1XxL8P/DOp/CKDx74Uj1jT4rfWBo+oabq11FeFWeIyRyxzRxRfKQpUqU4OOa7zxT8F/hf4X+PSfDBrrxReTXlzb2kWrx3duqWM08aeWrw+QTOA7qWYPFwxUDKbmq+vLbXt8k/ya/4cTslzdP+Db8/6sfN1KBkgDqa9T/4VBa2Hw++Jup6hcXP9teEtXtdLjSFlEEm+WWOQupUsT+7GMEd85rOufh9p1t8C9J8bLNdHVbrxDNpTwl18gRJAkgYDbu3ZY87sY7VVNxm/L3f/JrW/wDSkNpp8vXX8L3/ACZy/jTwXrPw98TXvh/xBZ/2fq9ns8+381JNm5FdfmQlTlWU8HvWJX1X8dPhva+Ov2n/AInalrN5Np3hbw7Z2uparcWqB53T7NbqkEIPyiSRmCqW4HLHOMHybWvAPhnX/hbceNvCC6tYDS9Qi0/VdJ1a6iu2QTBjDPHNHFECpKFSpTIODkis6cnOMW92l+Lsvx0/4BMfe/rsrv8ADU8tor3f9oX4V/Df4N6lrXhrT9V8Ran4tilgltlcQGzt7d0Vik7gK7SkEsNihQGUHJBrwinCamrodjU8M/8AIfsf+ugrzOvTPDP/ACH7H/roK8zrsl/Aj6y/KIBXVWX/AB5wf9c1/lXK11Vl/wAecH/XNf5V59XZFRMHV/8AkIy/h/IVTq5q/wDyEZfw/kKp1rH4US9woooqgCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA9G+HH/IEn/6+G/8AQVrqq5X4cf8AIEn/AOvhv/QVrqq+wwn8CHoUhJGKoxVS7AEhR1PtXP6L4YtzY+bqthbT6hO7SzNLGrkEnO3PPAGBXQ0Vu6cZS5mBxerabPpXhzxDaCHbZFvNtipGAGIyoA5GDnt3rYlsrqx1aPUbeA3SSW6wTwKyq4xyrLuIB6kEEir1/pCalKvnzzNbghjajaI2IOQTxuPOOM446VerCNGzv2tb5X/R2AxYNOnv9SuL+6iNqr2/2aKFmDOFJyzMQSATxwCen4Vk3Njq7eEn0aPTy1wkXk+eZUEbqD1XnOSOxA+tdhRVOhFpq71vf5j63MLU7G5TU9JvobdrkWyyRyRRsobDKMEbiAcEeveq95oaprVxeyaTFqsF0ibkZYzJEyjHG8gEEY6HqK6WiqlRjJ3fe4loc7qGkNPoqRWWmR2Lm5ilMCbFOFcEk7eM4HYmuiooq4wUb2AKo69/yA9Q/wCveT/0E1eqjr3/ACA9Q/695P8A0E0VPgfoB47Xb+H/APkS1/7CEn/otK4iu38P/wDIlr/2EJP/AEWlfI0dp+n6okWvQvgHqPhfQ/iroms+MbsW2iaU7ag0flSSG4liBaGIBFP3pAnLYXAOTXntFc12tiWlJWex61pn7UfxKtPG9tr9x408QXECagt7JprapObV18ze0XlF9uwjK7cYxxXV+I/G/gHSovjxZeHdYSbTvFCWU2iRJaTRhj9rWeWHDINnlgsPmwCFGCa+eqKz9nGyS6X/ABt/kacz5ubzT+ad0esaN440S0/Zo1zwpLe7dfufEttqEVp5TndAkDKz79u0YJAwTn2rf8dfEzw3rP7X9t42s9S87wwmuadeNfeRKuIohD5jbCofjY3G3JxwDXhFFaLSoqnVf5RX/tq/EiS5oeze2v4tv9T3+L4i+FPElx8a/Dl9rSaRp3izVjquk63PazvAHiupZEWVI1aRVkSTGQhKnqKzPHXiLwZp37Pvh3wZofiL+3dcs/EE+oX8kVpNDAyvAqhoTIikoMBfmCtkMdoGCfE6KiEVC1v7vz5bW/JL5FX1v6/imn+bPq7WP2iPDrfHf4nXOm+JdS0nw74wsrS0t/E+kRzRXFhNFFDsm2fJIYwyOrquGKk4z38k+InjDXb3w/HZ6h8ZL3x9HNcKW02K61OW3RVBPmSfa44huDbdoVW/iJK4G7yyiiEVDlt9m34O4o+6rL+tLfoemftJeMNI8f8Axt8T6/oN39v0m9khaC48p494WCNT8rgMOVI5A6V5nRRRCKhFRXQHqanhn/kP2P8A10FeZ16Z4Z/5D9j/ANdBXmddkv4EfWX5RAK6qy/484P+ua/yrla6qy/484P+ua/yrz6uyKiYOr/8hGX8P5CqdXNX/wCQjL+H8hVOtY/CiXuFFFFUAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHZ/D/AFqG1M1jMwj8xt8bE8E4wR+grva8Pq5HrF/EoVL65RR0CysB/OvXw+P9jDkkr2Hc9korx3+3NS/6CF1/3+b/ABo/tzUv+ghdf9/m/wAa6v7Th/KwuexUV47/AG5qX/QQuv8Av83+NH9ual/0ELr/AL/N/jR/acP5WFz2KivHf7c1L/oIXX/f5v8AGj+3NS/6CF1/3+b/ABo/tOH8rC57FRXjv9ual/0ELr/v83+NH9ual/0ELr/v83+NH9pw/lYXPYqK8d/tzUv+ghdf9/m/xo/tzUv+ghdf9/m/xo/tOH8rC57FXPeNNahsNKmtgwa4nUoEB5APUn8K8+OuaiRg6hdEf9dm/wAapvI0rlnYux6sxyTWNXMeeDjCNrhcbXX+DbyK70+40h3WKdpRPblzgM2MMv1IAx9K5CivJpz9nK7V11Eegy6bdwuUe2lVh1BQ037Fcf8APCX/AL4NcjF4j1a3QJFql7Gg6Klw4A/Wn/8ACVa1/wBBi/8A/Al/8a0/cd3+AHV/Yrj/AJ4S/wDfBo+xXH/PCX/vg1yn/CVa1/0GL/8A8CX/AMaP+Eq1r/oMX/8A4Ev/AI0Wod39y/zA6v7Fcf8APCX/AL4NH2K4/wCeEv8A3wa5T/hKta/6DF//AOBL/wCNH/CVa1/0GL//AMCX/wAaLUO7+5f5gdX9iuP+eEv/AHwaPsVx/wA8Jf8Avg1yn/CVa1/0GL//AMCX/wAaP+Eq1r/oMX//AIEv/jRah3f3L/MDq/sVx/zwl/74NH2K4/54S/8AfBrlP+Eq1r/oMX//AIEv/jR/wlWtf9Bi/wD/AAJf/Gi1Du/uX+YHV/Yrj/nhL/3waPsVx/zwl/74Ncp/wlWtf9Bi/wD/AAJf/Gj/AISnWv8AoL3/AP4Ev/jRah3f3L/MDtVY+G7dtSvAYXVD9nhfhpHIwDj0HXPtXm1S3F1NdymSeV5pD1eRix/M1FU1JxklCCsl+oBXVWX/AB5wf9c1/lXK11Vl/wAecH/XNf5Vw1dkVE5+8kF4kF4nMc6A5HYgYIqrXN6frN1prBY3DxE8wyDch/D/AArvrO0gvLdJXhUMQCQpIH86UaiSswaMWiug/sq1/wCeX/jx/wAaP7Ktf+eX/jx/xqvaxDlZz9FdB/ZVr/zy/wDHj/jR/ZVr/wA8v/Hj/jR7WIcrOforfOl2o/5Z/wDjx/xpP7Mtv+eX/jx/xo9rEOVmDRW9/Zlt/wA8v/Hj/jR/Zlt/zy/8eP8AjR7WIcrMGit7+zLb/nl/48f8aP7Mtv8Anl/48f8AGj2sQ5WYNFb39mW3/PL/AMeP+NH9l23/ADz/APHjR7WIcrMGit06bbf88/8Ax4/40n9m23/PP/x4/wCNHtYhysw6K3P7Ntv+ef8A48f8aP7Ntv8Ann/48f8AGj2sQ5WYdFbn9m23/PP/AMeP+NH9m23/ADz/APHj/jR7WIcrMOitz+zbb/nn/wCPH/Gj+zbb/nn/AOPH/Gj2sQ5WYdFbn9m23/PP/wAeP+NH9m23/PP/AMeP+NHtYhysw6K3P7Ntv+ef/jxo/s23/wCef/jxo9rEOVmHRW5/Ztv/AM8//HjTf7Ot/wDnn/48aPaxDlZi0Vtf2db/APPP/wAeNH9nW/8Azz/8eNHtYhysxaK2v7Ot/wDnn/48aP7Ot/8Ann/48aPaxDlZi0Vtf2db/wDPP/x40f2db/8APP8A8eNHtYhysxaK2v7Ot/8Ann/48aP7Ot/+ef8A48aPaxDlZi0Vtf2db/8APP8A8eNH9nW//PP/AMeNHtYhysxaK2v7Ot/+ef8A48aP7Ot/+ef/AI8aPaxDlZi0Vtf2db/88/8Ax40f2db/APPP/wAeNHtYhysxaK2v7Ot/+ef/AI8aP7Ot/wDnn/48aPaxDlZi0Vtf2db/APPP/wAeNH9nW/8Azz/8eNHtYhysxaK2v7Ot/wDnn/48aP7Ot/8Ann/48aPaxDlZi0VtjTbc/wDLP/x40v8AZtv/AM8//HjR7WIcrMOitz+zbb/nn/48aP7Ntv8Ann/48f8AGj2sQ5WYdFbn9m23/PP/AMeP+NH9m23/ADz/APHj/jR7WIcrMOitz+zbb/nn/wCPH/Gj+zbb/nn/AOPH/Gj2sQ5WYdFbn9m23/PP/wAeP+NH9m23/PP/AMeP+NHtYhysw6K3P7Ntv+ef/jx/xo/s22/55/8Ajx/xo9rEOVmHRW8NMtiP9X/48aP7Ltv+ef8A48aPaxDlZg0Vvf2Zbf8APP8A8eP+NH9mW3/PL/x4/wCNHtYhyswaK3v7Mtv+eX/jx/xo/sy2/wCeX/jx/wAaPaxDlZg0Vvf2Zbf88v8Ax4/40f2Zbf8APL/x4/40e1iHKzBore/sy2/55f8Ajx/xpRpltn/Vf+PH/Gj2sQ5WYFFdB/ZVr/zy/wDHj/jR/ZVr/wA8v/Hj/jR7WIcrOforoP7Ktf8Anl/48f8AGj+y7Uf8sv8Ax4/40e1iHKzBjjaV1RRlmOAKmvvFkWmXT2qguIcJuXpkAZ/WsvxFrVxZXBtrYJbLjmSMYc/8C7fhXN/e5PJPJJrKc+YaVj//2Q==)\n\nUne fois cet échange bidirectionnel d'identifiants terminé, Xeres se connectera directement et de manière sécurisée, sans utiliser aucun serveur tiers.\n\nRemarque : Un [serveur de chat](https://retroshare.ch/) en ligne est également disponible si vous souhaitez simplement tester le logiciel ou si vous cherchez à rencontrer d'autres personnes.\n\nAstuce : Vous pouvez également utiliser le bouton de code QR pour prendre une photo avec votre smartphone, puis afficher cette photo dans l'autre instance de Xeres en utilisant le petit bouton de scanneur de code QR dans la fenêtre d'ajout d'un pair."
  },
  {
    "path": "ui/src/main/resources/help/fr/02.Réseau.md",
    "content": "# Connexions\n\nLa connexion avec d'autres amis se fait automatiquement tant que vous les avez ajoutés comme expliqué dans [configuration rapide](01.Configuration%20rapide.md).\n\n![](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAjwCPAAD/4QBmRXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAAExAAIAAAAQAAAATgAAAAAAAACPAAAAAQAAAI8AAAABUGFpbnQuTkVUIDUuMS45AP/bAEMAAgEBAQEBAgEBAQICAgICBAMCAgICBQQEAwQGBQYGBgUGBgYHCQgGBwkHBgYICwgJCgoKCgoGCAsMCwoMCQoKCv/bAEMBAgICAgICBQMDBQoHBgcKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCv/AABEIAd0CIQMBEgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP38ooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKjaZw+xISecZPA6e/9KdieZXs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)\n\nXeres s'occupe automatiquement de trouver les pairs et de se connecter à eux. Cela peut prendre un certain temps, de quelques secondes à quelques minutes.\n\nN'oubliez pas que vous devez tous les deux avoir ajouté l'identifiant de l'autre, sinon cela ne fonctionnera pas.\n\nLe nombre d'amis connectés est également affiché en bas à gauche de la fenêtre principale. Il y a aussi un indicateur DHT et NAT dont la fonction est décrite ci-dessous.\n\n## Assistants\n\nPour améliorer les connexions dans un réseau dynamique et changeant, les fonctionnalités suivantes peuvent être utiles.\n\n### DHT\n\nDHT signifie Table de Hachage Distribuée et est un système qui aide deux pairs à se trouver lorsque leur adresse IP a changé ou est inconnue. Xeres utilise le DHT BitTorrent, également connu sous le nom de Mainline DHT. Si la LED n'est pas verte, la connexion aux amis pourrait être plus difficile.\n\n### NAT\n\nSi vous êtes derrière un NAT (Network Address Translation : la plupart des routeurs sont configurés avec un NAT), alors les connexions entrantes peuvent être restreintes. Xeres tente de contourner cela en utilisant le protocole UPNP. Assurez-vous que l'UPNP est activé sur votre routeur. Contrairement aux idées reçues,\nl'UPNP est sécurisé de nos jours, car tous les anciens bogues ont été résolus. La LED NAT dans Xeres doit être verte.\n"
  },
  {
    "path": "ui/src/main/resources/help/fr/04.Emojis.md",
    "content": "# Émojis\n\nLes alias peuvent être utilisés pour afficher rapidement certains émojis. Il est préférable de les insérer directement en utilisant le raccourci clavier de votre système d'exploitation (par exemple `Win`+`.` sur Windows).\n\n### Les plus courants\n\n:joy​: :joy:\n\n:grin​: :grin:\n\n:rofl​: :rofl:\n\n:yum​: :yum:\n\n:blush​: :blush:\n\n:rage​: :rage:\n\n:scream​: :scream:\n\n:cry​: :cry:\n\n:sob​: :sob:\n\n:sick​: :sick:\n\n:poop​: :poop:\n\n:muscle​: :muscle:\n\n:wave​: :wave:\n\n:eyes​: :eyes:\n\n:zzz​: :zzz:\n\n:fire​: :fire:\n\n:heart​: :heart:\n\n:boom​: :boom:\n\n### Pays\n\n:cc​: (le domaine Internet du pays, par exemple, ch, fr...)"
  },
  {
    "path": "ui/src/main/resources/help/fr/05.Arguments de démarrage.md",
    "content": "# Arguments de démarrage\n\nLors de l'exécution manuelle de Xeres, vous pouvez fournir les options de commande suivantes. Ceci est réservé à une utilisation avancée et n'est normalement pas nécessaire.\n\n- `--no-gui` : démarre sans interface utilisateur. Peut être utilisé pour exécuter Xeres en mode \"headless\". Utilisez une autre instance avec `--remote-connect` pour vous y connecter.\n- `--iconified` : démarre minimisé dans la zone de notification. Ceci est utile pour le démarrage automatique.\n- `--data-dir=<path>` : spécifie le répertoire de données. C'est ici que Xeres stocke tous ses fichiers utilisateur. Si vous souhaitez exécuter plusieurs instances, chacune doit avoir un répertoire de données différent.\n- `--control-address=<host>` : spécifie l'adresse à laquelle se lier pour les accès distants entrants (par défaut, uniquement localhost).\n- `--control-port=<port>` : spécifie le port de contrôle pour l'accès à distance. C'est le port auquel l'interface utilisateur se connectera. Si vous souhaitez exécuter plusieurs instances, chacune doit avoir un port de contrôle différent, mais Xeres tentera de trouver un emplacement libre automatiquement (en\n  commençant à partir de 1066), cet argument est donc rarement nécessaire.\n- `--no-control-password` : ne protège pas l'adresse de contrôle par un mot de passe. Le mot de passe est généré automatiquement lors du premier démarrage et est visible dans les paramètres. Il peut être modifié ou désactivé.\n- `--server-address=<host>` : spécifie une adresse locale à laquelle se lier (si non spécifiée, se lie à toutes les interfaces).\n- `--server-port=<port>` : spécifie le port local à utiliser pour les connexions entrantes. Par défaut, Xeres choisit un port aléatoire et l'utilise définitivement pour la même instance.\n- `--fast-shutdown` : ignore la procédure d'arrêt correcte. Ceci est surtout utile pour les tests lorsque vous devez exécuter/arrêter rapidement des instances de Xeres. Non nécessaire pour une utilisation normale.\n- `--server-only` : n'accepte que les connexions entrantes, n'établit pas de connexions sortantes. Principalement utile pour les serveurs de discussion.\n- `--remote-connect:<host>[:<port>]` : démarre en tant que client d'interface utilisateur et se connecte au nœud spécifié. Vous pouvez également faire cela entre des machines sur un réseau local. Attention, la connexion n'est pas chiffrée. Utilisez des tunnels SSH si vous souhaitez contourner cette limitation.\n- `--remote-password=<password>` : mot de passe à utiliser pour une connexion à distance.\n- `--version` : affiche la version du logiciel.\n- `--help` : affiche le message d'aide."
  },
  {
    "path": "ui/src/main/resources/help/fr/06.Liens.md",
    "content": "# Liens en ligne utiles\n\n## Xeres\n\n- [Page d'accueil](https://xeres.io)\n- [Actualités](https://xeres.io/news)\n- [Documentation & FAQ](https://xeres.io/docs)\n- [Discussions en ligne](https://github.com/zapek/Xeres/discussions)\n- [Feuille de route](https://github.com/users/zapek/projects/4)\n- [Problèmes](https://github.com/zapek/Xeres/issues)\n- [Wiki](https://github.com/zapek/Xeres/wiki)\n- [Page du projet GitHub](https://github.com/zapek/Xeres)\n\n## Tiers\n\n- [ChatServer](https://retroshare.ch): un serveur en ligne que vous pouvez utiliser si vous n'avez pas d'amis à connecter. Créé et maintenu par l'auteur de Xeres.\n- [Retroshare](https://retroshare.cc): le projet qui a tout commencé. Xeres est compatible avec celui-ci.\n- [Topologie réseau](https://retroshare.readthedocs.io/en/latest/concept/topology/): une bonne introduction à la topologie de réseau utilisée par Retroshare et Xeres.\n"
  },
  {
    "path": "ui/src/main/resources/help/ru/00.Index.md",
    "content": "Выберите раздел справки слева.\n\nКнопка «Домой» вернёт вас на эту страницу.\n\n[Быстрая настройка](01.Быстрая%20настройка.md) обязательна к прочтению для новых пользователей.\n\nСмотрите раздел [Ссылки](06.Ссылки.md) для получения перечня онлайн-ресурсов, которые вы можете посетить для получения дополнительной информации.\n\nИ помните, что вы можете навести указатель мыши на большинство элементов интерфейса, и через короткое время появится всплывающая подсказка с пояснением.\n"
  },
  {
    "path": "ui/src/main/resources/help/ru/01.Быстрая настройка.md",
    "content": "# Создание профиля\n\nЕсли вы запускаете Xeres впервые, вам необходимо создать **профиль** и **локацию**.\n\nПрофиль — это по сути вы (как личность), а локация — ваше устройство. У вас может быть несколько локаций, например, стационарный компьютер и ноутбук, на каждом из которых работает ваш профиль.\n\nВы можете экспортировать профиль с первого устройства для использования на другом. Для этого используйте меню `Инструменты / Экспорт`, затем импортируйте его при создании аккаунта на другом устройстве.\n\n# Добавление друзей\n\nХотя Xeres может работать самостоятельно, он становится гораздо интереснее, когда вы начинаете подключаться к друзьям.\n\nОсновная концепция заключается в обмене идентификаторами. Вы передаёте свой ID друзьям, а друзья передают свои ID вам. Только после такого обмена вы сможете быть подключены друг к другу.\n\n![](data:image/jpeg;base64,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)\n\nВы можете сделать это прямо из панели **Главная**. Нажмите на следующую кнопку справа от вашего ID, чтобы скопировать его в буфер обмена.\n\n![Copy To Clipboard](data:image/jpeg;base64,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)\n\nЗатем вы можете вставить этот ID в любом удобном способе коммуникации, чтобы отправить другу, например:\n\n- через другой мессенджер\n- по электронной почте\n- через SMS\n- в текстовом файле на USB-накопителе\n\nКогда друг передаст вам свой ID, нажмите кнопку **Добавить участника**.\n\n![Add Peer](data:image/jpeg;base64,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)\n\nЭто откроет следующее окно, где вы можете вставить ID друга и нажать кнопку **Добавить**.\n\n![Adding Friend](data:image/jpeg;base64,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HcqUVb/s2X+8n5n/AAo/s2X+8n5n/CjlYXKlem6R/wAgmy/64J/6CK89/s2X+8n5n/CvQ9LUpplop6iFB/46KmaaWo1uc7rn/IUn/wCA/wDoIqhV/XP+QpP+H/oIqhXTH4UQ9woooqgCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoor9L/ilb6hYfF3Vvgr8K/g14B1dbHw6l39q1Czijuo4mCxtJ5rMoLhpUIJyxJyc81EpcoJH5oUV6r8ZP2YviD8BNN06+8Z6VBp1tqErQW7RXkU5ZlGSMIxxx616L8CP2Sfi9eSeFfiLoPgzR/EmkMRe21rqt7B5FyvI2yRs4OM54PpQ5K17hY+ZaK+6viXqVj8U/2YPjJe+Ifhx4S8IeLvAmt2mnRyeHLJYWjlN3HDMC4J3Dlxwdp4PYGvhWiMuYGFFFFWAV2lh/x423/XNf5Vxddnp/8Ax4W3/XNf5VhV2RUTmtb/AOQpN/wH/wBBFUava3/yE5v+A/8AoIqjWsfhRL3CiiiqAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACvp79hDxmugfFHxjearpuveILS48I3lrcjRo1muYYTNblpTvdcKoB5ycErxXzDX1X+w58MZLz4v+MNI8V2PibSDa+Ebu8k0/T7m5028uFE1uPLOwozq4YjYflY7eOBWc7cruNbn0V8KPiN4S8R23w0j+HHg7xz4u8HeCbzUkvJr2yhuJi9zC7KpPmAMQ82ecYXHXFUvAvxK8GW2gfAVPEnhrx9H4h0t7w+HotLtohbalI0imRcGTMgXEYx8uCTW38N/BsXgS4+FWrfB7RtY8FyeKbrVPtfhnxhqd4ls/kwyKGngVmG7Ee5TtJxs5rhY/A3/Cu/+EX8L/8ACcW3jUa3LLFd+KbK8+1R/D0o4bzbKbP+iCYyEEny93kD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gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/8AX0P5GuLrtPg1/wAlR8N/9fQ/ka4uu2f+60/8UvygHQK6+x/48rf/AK5r/KuQrr7H/jyt/wDrmv8AKvLq7IqJzus/8hKb8P5CqVXdZ/5CU34fyFUq1j8KJe52nh3/AJJd4z/6+tO/nPXF12nh3/kl3jP/AK+tO/nPXF134j+HR/wv/wBLkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/+vofyNcXXafBr/kqPhv8A6+h/I1xdds/91p/4pflAOgV19h/x5W//AFzX+VchXX2H/Hlb/wDXNf5V5dXZFROd1n/kJTfh/IVSq7rP/ISm/D+QqlWsfhRL3O08O/8AJLvGf/X1p38564uu08O/8ku8Z/8AX1p38564uu/Efw6P+F/+lyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/X0P5GuLrtPg1/yVHw3/ANfQ/ka4uu2f+60/8UvygHQK6+w/48rf/rmv8q5CuvsP+PK3/wCua/yry6uyKic7rH/ISm/D+QqlV3WP+QlN+H8hVKtY/CiXudp4d/5Jd4z/AOvrTv5z1xddp4d/5Jd4z/6+tO/nPXF134j+HR/wv/0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/wCSo+G/+vofyNcXXafBr/kqPhv/AK+h/I1xdds/91p/4pflAOgV1tl/x5wf9c1/lXJV1tl/x5wf9c1/lXl1dkVE5/WP+QlN+H8hVKrusf8AISm/D+QqlWsfhRL3O08O/wDJLvGf/X1p38564uu08O/8ku8Z/wDX1p38564uu/Efw6P+F/8ApcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdArrbL/jzg/65r/KuSrrbL/jzg/65r/KvLq7IqJz+sf8AISm/D+QqlV3WP+QlN+H8hVKtY/CiXudp4d/5Jd4z/wCvrTv5z1xddp4d/wCSXeM/+vrTv5z1xdd+I/h0f8L/APS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv8A6+h/I1xddp8Gv+So+G/+vofyNcXXbP8A3Wn/AIpflAOgV1tl/wAecH/XNf5VyVdbZf8AHnB/1zX+VeXV2RUTn9Y/5CU34fyFUqu6x/yEpvw/kKpVrH4US9ztPDv/ACS7xn/19ad/OeuLrtPDv/JLvGf/AF9ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/19D+Rri67T4Nf8lR8N/wDX0P5GuLrtn/utP/FL8oB0Cutsv+POD/rmv8q5Kutsv+POD/rmv8q8ursionP6x/yEpvw/kKpVd1j/AJCU34fyFUq1j8KJe52nh3/kl3jP/r607+c9cXXaeHf+SXeM/wDr607+c9cXXfiP4dH/AAv/ANLkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/8Ar6H8jXF12nwa/wCSo+G/+vofyNcXXbP/AHWn/il+UA6BXW2X/HnB/wBc1/lXJV1tl/x5wf8AXNf5V5dXZFROf1j/AJCU34fyFUqu6x/yEpvw/kKpVrH4US9ztPDv/JLvGf8A19ad/OeuLrtPDv8AyS7xn/19ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK62y/wCPOD/rmv8AKuSrrbL/AI84P+ua/wAq8ursionP6x/yEpvw/kKpVd1j/kIzfh/IVSrWPwol7naeHf8Akl3jP/r607+c9cXXaeHf+SXeM/8Ar607+c9cXXfiP4dH/C//AEuQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/6+h/I1xddp8Gv+So+G/wDr6H8jXF12z/3Wn/il+UA6BXW2X/HnB/1zX+VclXW2X/HnB/1zX+VeXV2RUTn9Y/5CM34fyFUqu6x/yEZvw/kKpVrH4US9ztPDv/JLvGf/AF9ad/OeuLrtPDv/ACS7xn/19ad/OeuLrvxH8Oj/AIX/AOlyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf8A19D+Rri67T4Nf8lR8N/9fQ/ka4uu2f8AutP/ABS/KAdArrLH/jyg/wCua/yrk66yx/48oP8Armv8q8ursiomBrH/ACEZvw/kKpVd1j/kIzfh/IVSrWPwol7naeHf+SXeM/8Ar607+c9cXXaeHf8Akl3jP/r607+c9cXXfiP4dH/C/wD0uQBRRRXEAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQB2nwa/5Kj4b/AOvofyNcXXafBr/kqPhv/r6H8jXF12z/AN1p/wCKX5QDoFdZY/8AHlB/1zX+VcnXWWP/AB5Qf9c1/lXl1dkVEwNY/wCQjN+H8hVKrmr/APIRl/D+QqnWsfhRL3O08O/8ku8Z/wDX1p38564uu08O/wDJLvGf/X1p38564uu/Efw6P+F/+lyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/ACVHw3/19D+Rri67T4Nf8lR8N/8AX0P5GuLrtn/utP8AxS/KAdArrLI4s4P+ua/yrk66qy/484P+ua/yry6uyKiYOr/8hGX8P5CqdXNX/wCQjL+H8hVOtY/CiXudp4d/5Jd4z/6+tO/nPXF12nh3/kl3jP8A6+tO/nPXF134j+HR/wAL/wDS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/AK+h/I1xddp8Gv8AkqPhv/r6H8jXF12z/wB1p/4pflAOgV1Vl/x5wf8AXNf5VytdVZf8ecH/AFzX+VeXV2RUTB1f/kIy/h/IVTq5q/8AyEZfw/kKp1rH4US9ztPDv/JLvGf/AF9ad/OeuLrtPDv/ACS7xn/19ad/OeuLrvxH8Oj/AIX/AOlyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf8A19D+Rri67T4Nf8lR8N/9fQ/ka4uu2f8AutP/ABS/KAdArqrL/jzg/wCua/yrla6qy/484P8Armv8q8ursiomDq//ACEZfw/kKp1c1f8A5CMv4fyFU61j8KJe52nh3/kl3jP/AK+tO/nPXF12nh3/AJJd4z/6+tO/nPXF134j+HR/wv8A9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/wDr6H8jXF12nwa/5Kj4b/6+h/I1xdds/wDdaf8Ail+UA6BXVWX/AB5wf9c1/lXK11Vl/wAecH/XNf5V5dXZFRMHV/8AkIy/h/IVTq5q/wDyEZfw/kKp1rH4US9ztPDv/JLvGf8A19ad/OeuLrtPDv8AyS7xn/19ad/OeuLrvxH8Oj/hf/pcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/wAlR8N/9fQ/ka4uu0+DX/JUfDf/AF9D+Rri67Z/7rT/AMUvygHQK6qy/wCPOD/rmv8AKuVrqrL/AI84P+ua/wAq8ursiomDq/8AyEZfw/kKp1c1f/kIy/h/IVTrWPwol7naeHf+SXeM/wDr607+c9cXXaeHf+SXeM/+vrTv5z1xdd+I/h0f8L/9LkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv8AkqPhv/r6H8jXF12nwa/5Kj4b/wCvofyNcXXbP/daf+KX5QDoFdVZf8ecH/XNf5VytdVZf8ecH/XNf5V5dXZFRMHV/wDkIy/h/IVTq5q//IRl/D+QqnWsfhRL3O08O/8AJLvGf/X1p38564uu08O/8ku8Z/8AX1p38564uu/Efw6P+F/+lyAKKKK4gCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAO0+DX/JUfDf/X0P5GuLrtPg1/yVHw3/ANfQ/ka4uu2f+60/8UvygHQK6qy/484P+ua/yrla6qy/484P+ua/yry6uyKiYOr/APIRl/D+QqnVzV/+QjL+H8hVOtY/CiXudp4d/wCSXeM/+vrTv5z1xddp4d/5Jd4z/wCvrTv5z1xdd+I/h0f8L/8AS5AFFFFcQBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAHafBr/kqPhv/r6H8jXF12nwa/5Kj4b/AOvofyNcXXbP/daf+KX5QDoFdVZf8ecH/XNf5VytdVZf8ecH/XNf5V5dXZFRMHV/+QjL+H8hVOrmr/8AIRl/D+QqnWsfhRL3O08O/wDJLvGf/X1p38564uu08O/8ku8Z/wDX1p38564uu/Efw6P+F/8ApcgCiiiuIAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDtPg1/yVHw3/wBfQ/ka4uu0+DX/ACVHw3/19D+Rri67Z/7rT/xS/KAdArqrL/jzg/65r/KuVrqrL/jzg/65r/KvLq7IqJg6v/yEZfw/kKp1c1f/AJCMv4fyFU61j8KJe52nh3/kl3jP/r607+c9cXXaeHf+SXeM/wDr607+c9cXXfiP4dH/AAv/ANLkAUUUVxAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAdp8Gv+So+G/8Ar6H8jXF12nwa/wCSo+G/+vofyNcXXbP/AHWn/il+UA6BXVWX/HnB/wBc1/lXK11Vl/x5wf8AXNf5V5dXZFRMHV/+QjL+H8hVOrmr/wDIRl/D+QqnWsfhRL3O08O/8ku8Z/8AX1p38564uu08O/8AJLvGf/X1p38564uu/Efw6P8Ahf8A6XIAoooriAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA7T4Nf8lR8N/wDX0P5GuLrtPg1/yVHw3/19D+Rri67Z/wC60/8AFL8oB0Cuqsv+POD/AK5r/KuVrqrL/jzg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)\n\nПосле того как этот двусторонний обмен ID завершён, Xeres установит прямое и безопасное соединение без использования сторонних серверов.\n\nПримечание: также доступен онлайн [Чат-сервер](https://retroshare.ch/), если вы просто хотите попробовать программу или найти друзей.\n\nПодсказка: вы также можете использовать кнопку QR-кода, сделать снимок на смартфон и показать его другому экземпляру Xeres с помощью кнопки сканера QR-кода в окне добавления участника."
  },
  {
    "path": "ui/src/main/resources/help/ru/02.Сеть.md",
    "content": "# Подключения\n\nСоединение с другими друзьями устанавливается автоматически, как только вы добавили их, как описано в разделе [Быстрая настройка](01.Быстрая%20настройка.md).\n\n![](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAjwCPAAD/4QBmRXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAAExAAIAAAAQAAAATgAAAAAAAACPAAAAAQAAAI8AAAABUGFpbnQuTkVUIDUuMS45AP/bAEMAAgEBAQEBAgEBAQICAgICBAMCAgICBQQEAwQGBQYGBgUGBgYHCQgGBwkHBgYICwgJCgoKCgoGCAsMCwoMCQoKCv/bAEMBAgICAgICBQMDBQoHBgcKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCv/AABEIAd0CIQMBEgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP38ooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKjaZw+xISecZPA6e/9KdieZXs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)\n\nXeres автоматически заботится о поиске участников и подключении к ним. Это может занять некоторое время — от нескольких секунд до пары минут.\n\nПомните, что вы оба должны добавить ID друг друга, иначе соединение не будет работать.\n\nКоличество подключенных друзей также отображается в нижнем левом углу главного окна. Там же находятся индикаторы DHT и NAT, функции которых описаны ниже.\n\n## Вспомогательные механизмы\n\nДля улучшения соединений в динамичной и изменчивой сети могут помочь следующие функции.\n\n### DHT\n\nDHT означает «Распределённая хэш-таблица» — это система, которая помогает двум участникам найти друг друга, когда их IP-адрес изменился или неизвестен. Xeres использует BitTorrent DHT, также известную как Mainline DHT. Если индикатор не зелёный, то подключение к друзьям может быть затруднено.\n\n### NAT\n\nЕсли вы находитесь за NAT (Преобразование сетевых адресов: большинство маршрутизаторов настроены с использованием NAT), то входящие соединения могут быть ограничены. Xeres пытается обойти это, используя протокол UPnP. Убедитесь, что UPnP включен на вашем маршрутизаторе. Вопреки распространённому мнению, UPnP сегодня\nбезопасен, так как все старые ошибки были исправлены. Индикатор NAT в Xeres должен быть зелёным.\n"
  },
  {
    "path": "ui/src/main/resources/help/ru/04.Эмодзи.md",
    "content": "# Эмодзи\n\nПсевдонимы можно использовать для быстрого отображения некоторых эмодзи. Предпочтительнее вставлять их напрямую, используя комбинацию клавиш вашей операционной системы (например, `Win`+`.` в Windows).\n\n### Наиболее распространенные\n\n:joy​: :joy:\n\n:grin​: :grin:\n\n:rofl​: :rofl:\n\n:yum​: :yum:\n\n:blush​: :blush:\n\n:rage​: :rage:\n\n:scream​: :scream:\n\n:cry​: :cry:\n\n:sob​: :sob:\n\n:sick​: :sick:\n\n:poop​: :poop:\n\n:muscle​: :muscle:\n\n:wave​: :wave:\n\n:eyes​: :eyes:\n\n:zzz​: :zzz:\n\n:fire​: :fire:\n\n:heart​: :heart:\n\n:boom​: :boom:\n\n### Страны\n\n:cc​: (домен в Интернете для страны, например, ch, fr, и т.д.)"
  },
  {
    "path": "ui/src/main/resources/help/ru/05.Аргументы запуска.md",
    "content": "# Аргументы запуска\n\nПри ручном запуске Xeres вы можете использовать следующие параметры командной строки. Это предназначено для опытных пользователей и обычно не требуется.\n\n- `--no-gui`: запуск без пользовательского интерфейса. Может использоваться для запуска Xeres в headless-режиме. Используйте другой экземпляр с `--remote-connect` для подключения к нему.\n- `--iconified`: запуск свёрнутым в системный трей. Это полезно для автозапуска.\n- `--data-dir=<path>`: указание директории данных. Здесь Xeres хранит все пользовательские файлы. Если вы хотите запустить несколько экземпляров, каждый из них должен иметь отдельную директорию данных.\n- `--control-address=<host>`: указание адреса для входящих удалённых подключений (по умолчанию только localhost).\n- `--control-port=<port>`: указание порта для удалённого доступа. Это порт, к которому подключится пользовательский интерфейс. Если вы хотите запустить несколько экземпляров, каждый должен иметь уникальный порт управления, но Xeres попытается автоматически найти свободный слот (начиная с 1066), поэтому этот аргумент\n  редко нужен.\n- `--no-control-password`: отключает защиту адреса управления паролем. Пароль автоматически генерируется при первом запуске и виден в настройках. Его можно изменить или отключить.\n- `--server-address=<host>`: указание локального адреса для привязки (если не указано, привязывается ко всем интерфейсам).\n- `--server-port=<port>`: указание локального порта для входящих подключений. По умолчанию Xeres выбирает случайный порт и постоянно использует его для того же экземпляра.\n- `--fast-shutdown`: игнорирует правильную процедуру завершения работы. В основном полезно для тестирования, когда нужно быстро запускать/останавливать экземпляры Xeres. Не нужно для обычного использования.\n- `--server-only`: принимает только входящие подключения, не устанавливает исходящие. В основном полезно для чат-серверов.\n- `--remote-connect:<host>[:<port>]`: запускается как UI-клиент и подключается к указанному узлу. Также можно делать это между машинами в локальной сети. Учтите, что соединение не шифруется. Используйте SSH-туннели, чтобы обойти это ограничение.\n- `--remote-password=<password>`: пароль для удалённого подключения.\n- `--version`: вывод версии программы.\n- `--help`: вывод справочного сообщения."
  },
  {
    "path": "ui/src/main/resources/help/ru/06.Ссылки.md",
    "content": "# Полезные онлайн-ссылки\n\n## Xeres\n\n- [Домашняя страница](https://xeres.io)\n- [Новости](https://xeres.io/news)\n- [Документация и FAQ](https://xeres.io/docs)\n- [Онлайн-обсуждения](https://github.com/zapek/Xeres/discussions)\n- [План разработки](https://github.com/users/zapek/projects/4)\n- [Проблемы и ошибки](https://github.com/zapek/Xeres/issues)\n- [Вики](https://github.com/zapek/Xeres/wiki)\n- [Страница проекта на GitHub](https://github.com/zapek/Xeres)\n\n## Сторонние ресурсы\n\n- [ChatServer](https://retroshare.ch): онлайн-сервер, который можно использовать, если у вас пока нет друзей для подключения. Создан и поддерживается автором Xeres.\n- [Retroshare](https://retroshare.cc): проект, с которого всё началось. Xeres совместим с ним.\n- [Топология сети](https://retroshare.readthedocs.io/en/latest/concept/topology/): хорошее введение в топологию сети, используемую Retroshare и Xeres.\n"
  },
  {
    "path": "ui/src/main/resources/help/zh/00.Index.md",
    "content": "请在左侧选择一个帮助主题。\n\n点击主页按钮可返回此页面。\n\n首次使用的用户务必阅读[快速设置](01.快速设置.md)指南。\n\n访问[链接](06.链接.md)主题可获取更多在线资源。\n\n请注意，将鼠标悬停在大多数界面元素上，稍后会显示包含说明的工具提示。"
  },
  {
    "path": "ui/src/main/resources/help/zh/01.快速设置.md",
    "content": "# 创建个人资料\n\n如果是第一次运行 Xeres，你需要创建一个**个人资料**和一个**位置**。\n\n个人资料代表你自己（使用者），而位置则代表你的设备。你可以拥有多个位置，比如台式机和笔记本电脑，每个设备都可以运行你的个人资料。\n\n你可以将第一台设备上的个人资料导出，然后在另一台设备上使用。通过 `工具 / 导出` 菜单进行操作，然后在另一台设备创建账户时导入该资料。\n\n# 添加好友\n\n虽然 Xeres 可以独立运行，但当你开始与好友建立连接时，使用体验会变得更加有趣。\n\n其核心概念是交换身份标识。你将你的 ID 提供给好友，好友也将他们的 ID 提供给你。只有完成这一交换过程，你们才能成功建立连接。\n\n![](data:image/jpeg;base64,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)\n\n你可以直接在**主页**面板完成此操作。点击 ID 右侧的按钮即可将其复制到剪贴板。\n\n![复制到剪贴板](data:image/jpeg;base64,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)\n\n然后，你可以通过以下方式将 ID 发送给好友：\n\n- 其他聊天应用\n- 电子邮件\n- 短信\n- 保存在 U 盘中的文本文件\n\n当你收到好友的 ID 后，点击**添加节点**按钮。\n\n![添加节点](data:image/jpeg;base64,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)\n\n此时会弹出以下窗口，你可以在其中粘贴好友的 ID，然后点击**添加**按钮。\n\n![添加好友](data:image/jpeg;base64,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BjJH41AMyZOMSp0llkNR7ilc7nrA6JMdzUL/mpt3x9VXfTieefa3seykk+L2b/xLNDVN6v1d3L00NU2K27/xL93h4rdx9VUfjubm4XlbhwEFjFJqampixowZBWW7d++Otra22L278I3PmDGjIKBUyr333hvr1q3LFkdExPHHHx//+Z//mS3u00UXXRTX33BDz3LKKadERBSUXX/DDfGpP//z7EM5nHr/fQ6sFZRtpZXbWss+z0Cec9grdycMVDk7eFTtyCHV114tR397vr96AHprbm6Oq6+6qjtkvLnknIyDPRdv7g4X+T0Xw+8TeEBDpPqzceOGaGlp6fl3blhSTn19fcyePbvn3yndcsstsXTp0rj66qt7gsUll1zSK/gsW7Ys3ve+98W//du/xTPPPFNQl7Vly5aSvRy5ORML84ZI3X///RERcf7550dV9xCpYvMgyhleVUnz5s2LG5YvjxdeeKFgQvdJefvm2SL75uy3vW1kzL0oV7IzP6Ovv/NKPWf08byH9JwDfHBuSFVFfsnKf4OV2P7oV+oUAca2FEOkIm87hkgNXF8Boq+6QsPnuzFpwOiazL0lW9xjxowZMXFi71TW28Bf0qc+9am4+uqrI7ond997771x//33x4JDOMlXr14d559/frY4IiK+9a1vxeLFi+Oss87qKTvUgPHkb37TayL6YPQ3P2Igcy2y+tv2iDLw06w8fbXiDsdzxqE+7wAePGQBIwb2uoapqsPwLrJ7ESB9wDg6WzWMDN9PwWJBIiJ6lRUPF3EYvlFKSzpWqb/Lm/VXfyhuvvnmuOiii2Lnzp3xj//4j/GpT30qzj///Fi4cGHP8vDDD8e+ffvisssu6ylbsWJFbN++Pc4555yCdRcuXFgyXER3L0B+b03WYA7xqW95S6+5DosWLow777gjontSeH/LQJQz1yK33DQaLztXqc+YUge/VPmwV1X+zsrN0ygqt50BbK+kUs8xshyOd9FZxgIweg3fT7liw6XKDxeR4Ls1naQBI3/idlZVVVXUJprEXcq6devi/e9/f6xevToefPDBgrrly5fH2972trjrrrsKhvhcc8010draGj/4wQ9i3rx5BY/pS319fd6NBRmxsm3eFG3fKNJSq/TnWaW3H3GIO6bYY4uV9UUTeKjYywwHJ775zUkXOGj4fsplQ0b54SIn26AZzHLokgaMqqqqqCpxWctS5cUNZN1C69ati/PPP78gRNxyyy1xxRVXxMMPPxzLly+PK6+8siBMvP/974+IiB/84Aclb7KXNXv27H7vFZFT6tfBYmUME4M/BQsN5QHOnmj5z5397Ej7OcJh4vABHIrsF+ZgF7KSBozOzs6SlykrVV5athU0kKXLvHnz4t///d/jfe97X6xYsSL+6I/+KCIibrjhhvj85z/fs16u5yMi4o477ojbbrutz96M3KVoH3744WwVo8loab3196c3Wt7nKJX9dMt+0lXy8PV36kAlPffb3yZdoHLSfVpm52H0d3Wp4SppwGhra8sW9ejs7Iy2tgPZ4oo4/fTT45JLLon77rsvjj766PjHf/zHuCYz0Tr/6lbRHTLOOuusePjhh+PCCy+M++67L771rW8VrJNzwQUXRETEfffdl60a9LCpFKdmrvfl+eefz1YVddKyZb3u41FqOWnZsuzDx4ZSrbdiLb0xx04YCtnfyYb6N7Pscw7lcwMUyn7vDKfl0GXDxdVXXdVrTsZICRlJA0Zfk56jjPpU/vmf/zlOO+20+NnPfhZXXHFF3HzzzSWHPs2bN6+gt+JrX/taXHbZZfHiiy/Gb37zm4J1c84888zYuHFjr/tsTJ48uexhU8UM9Zf2+973vl738Si1vO9978s+fOzIfoYU+xwpVjac9Hdylf36B7sTir2AbFl/O3kwUm2HrOzRA6iclN8LlZT9Kab8peuu3l/vDhfPxtVX/XE0N++K5uZdcfVVfxzPPPtsd8j4ejQ1Tez1+LTLoUsaMPbsKbypXkNDQ8G/szfdq4TTTz89pk6dGrt27YpPfvKT8fjjj8enPvWpuOuuu4oOe7rnnnti+fLlEd1zNe7ovmLT7//+78fNN9+cWbtr+7Nnz47HHnssW5XkErPZQ5x/qLP/zjrhhBMiIvq9T8U3v/nNXleJKncZNZeorYTh/tnX18kTRdr3fS2DUuysrqTcCx30Cx4xsoenvyWVoTiKwFiX8lNreDrYc3FSd7gonNB9cOL3s913/B7+PRnJAkZLy76CHoqqqqqYPn1GweTulpaWivdi/MmfXBP79u2Lm2++pafsPe95T6xevbpXj0NExI9//ON429veFvPmzYtPfvKTsWnTpvjCF76QXa3HX/3VX0VExHe+851sVUT3UKvD5ey3va2sIVq5YU85l1xySc+8kpzly5cXlM2bNy/+n3//954wxgg2VO37YWV0fkGNucMIjBDZnzUOZRnd+gsXOSMtZCQJGO3t7bFlS+EN9iZOnBi1tbUxceLEgvItW7ZER0dHQVlKp556arz44os9X71XXnllLFiwoKdnIuu2226L6L5cbXTfpG/BggVFG9KXXHJJnHLKKfHUU0/16iXINdhXd99kJmf69OkFcxn++MorY/r06RFF/oT6WrLrR0T8v/ffH6+uWtWzXHjhhfHRj3yk57mLmTdvXvzFddfFe9773p6y9156aXw+E6rOe/vb41N//uc9/163bl2sXbs2PnTFFb3CCHmKHaxc2XCT7VDILoOSfePDZScM+g0Ne9nDVmqplOzzlFoARr/s917fS1PTpLj9G//SHS5+G1df9eFobt5d8jtzJIWMAQWM9rYD0XGgtSAgtLW1xaZNGwsmeFdXV8fkyVMiImLy5CkFvRhtbQdi48YNBet3dHREx4HWaD/ESeBXXnllTJ06NR566KGI7sb0n/3Zn8Xq1atLDu1Zt25dvPjii/Gud70ront40cMPPxyXXXZZwZCqefPmxd/8zd/Evn374n/9r/+Vt4UuuTuGZydYn3LKKXHDDTf0LNffcEOccsopBesM1n85//xeQ5iywSfrf//jP0ZExJ99/OM9ZY88/HA0NDQUBId77rknFixYUFD2J9dcE6tXr44bb7qp6HAzuhX7XChWNtz11Soc1Pvp70H9NUnzP5jL0de2csrd1siXvxcOx7vu6ygAh1dudMnpZ5wxwNsKHHTBhRdGDOF825Gu2ITuwp6L4t95IyVklBUwOtv2x2cWrItVF6+N1969Pu4/e00sqt4W27ZtjXXr1sb+/fsL1p86dVrPTfdqa2tj2rRpBfX79++P9evXxbZtW2NR9ba4/+w18dq718eqi9fGZxasi862wu2V64orPhTbt2/vmTuxfPnyqK+vj+uuuy67aoFnn302Jk+e3NNo/sxnPhMtLS0FQeBb3/pWzJ49O372s5/1NOJvueWWuO222+K2226Lyy67LLZv396rgb9ixYpYmAkBK1asKFinkvKbVzffckuccsopcfvttxcM5cqFr9NOO62n7Jabb459+/bF5Zdf3lMWEfGZ666L+vr6gsv8UqbBfWYPX70/94ZQiifObSPFtkae/K+u/pZUhAwYnu758Y8jIuJrt98e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AMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJJMsYLR37I+IiJqa8dkqAADgMMu103Pt9kpJFjD27NsSERETG2dlqwAAgMMs107PtdsrJVnA2N68JiIipk9enK0CAAAOs1w7Pddur5RkAeON7S9ERMS8GSdnqwAAgMMs107PtdsrJVnAeH3T47GvdUcsmveOmD39hGw1AABwmMyefkIsmveO2Ne6I17f9Hi2OqlkAWPDlmdi9fqHYkLDjHjT/ItN9gYAgGGgpmZ8vGn+xTGhYUasXv9QbNjyTHaVpJIFjIiIF1b/JLY3vxYnHXNZnLjofdlqAABgiJ246H1x0jGXxfbm1+KF1T/JVidXM2PmvOXZwsFq3rshqqtrYs70k+KoWafHgbZ9sWXny9HZ2Z5dFQAAqKCamvFx0pL/Fme9+f8X7e3746nffStefv2+7GrJJQ0YEREbtz4XNTXjY/b0E2PRvLdH3biJ0XpgV+zetzm7KgAAUAGzp58Qbzn2g3HGCdd0hYuXvhNPvnhndrWKqFp6/Bmd2cIUTj728jhx8e/H1KajY8++LfHqul/Eui1Px9adK2P33k3R3l7ZG3wAAMBYUVMzPiY2zorpkxfHvBknx6J574gJDTNie/Nr8dzKH8bTL30v+5CKqVjAiIiYN/MtcdyCd8eCuedEQ92UbDUAAFAB+1p3xOr1D8ULq38S6zb/JltdURUNGDlzZiyLo2adHkdMPS6mNs2PCQ0zoqbaVaYAACCF9o79sWffltjevCbe2P5CvL7p8YpfLaqUIQkYAADA2JD0MrUAAMDYJmAAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyVUuPP6MzW5ja5AlHxdSmhdHUODca66bH+HETo7qqJrsaAAAwCB2d7bH/wO7Y27o1mveuj+3Nq2Lnntezqw2JigaMKROPjtnTlsX0ScfEuNrGbDUAAFABB9r2xtZdL8fGbc/Ejt2vZasrqmIB48iZb425M06Nxrrpsf9Ac2ze+bvYuXtN7Gl5I1oP7IqOjrbsQwAAgEGorq6NunGTYkL9ETF54vyYOflNMX5cU+xt3RrrtzwZazf/KvuQiqlIwFgw+7w4auaZUVNTF+u2PB6btv02du1dl10NAACogEmN82LWtDfHvBmnR3t7a7y++bFYvfGB7GoVUTNj5rzl2cJDceTMt8bRs86JqKqKV9eviDVvPBwt+3dmVwMAACqk9UBz7Ni9Og607Y2pTQtjcuOR0dHZNiQ/+ie9itSUiUfH3BmnRk1NXazacH+s3fxrQ6EAAOAw6Ohoi7Wbfx2rNtwfNTV1MXfGqTFl4tHZ1ZJLGjBmT1sWjXXTY92Wx2P91t9kqwEAgCG2futvYt2Wx6OxbnrMnrYsW51csoAxecJRMX3SMbH/QHNs2vZbPRcAADAMdHS0xaZtv439B5pj+qRjYvKEo7KrJJUsYExtWhjjahtj887fDcnYLgAAoDy79q6LzTt/F+NqG2Nq08JsdVLJAkZT49yIiNi5e022CgAAOMxy7fRcu71SkgWMxrrpERGxp+WNbBUAAHCY5drpuXZ7pSQLGOPHTYyIiNYDu7JVAADAYZZrp+fa7ZWSLGBUV9VEdE8iAQAAhpdcOz3Xbq+UZAEDAABAwAAAAJIRMAAAgGQEDAAAIBkBAwAASEbAAAAAkhEwAACAZAQMAAAgGQEDAABIRsAAAACSETAAAIBkBAwAACAZAQMAAEhGwAAAAJIRMAAAgGQEDAAAIBkBAy65Ke6+dkm2FACAQRAwRqKlV8fdt1yULe3tkpviqXLWS2ZJfOJHK+KpJ7uXijz3kjhuabashEtuiqeevCneky3PeM8FZ8f8D301nnpyRdx6Sba2TEO+r0s77tpvxlNPrhgBoemiuDV3rpRxnACAkUHAGIlefC1Wn/eZrobZj66O47L1+cpdrwIe+Pl92aIk3vW/ygwv914f1z9wdtz05DfjEyVDyZJYvKD7f9d8L1a8MrhG+XsuOLtnXw86pCSyZMGREREx/0NfLQwZS6+Ou3sa9ANfkr+vS86N87r/d82dd8Y9mWoAYGQSMEa0R+L637s9XsgWZz3w+TilnPVGhFfiS3/1vVhz3mfiqT6DQ5d7fv5IRBwZV3y7xLpL3xHnz4+efXnPi69k1yjDRXFhd0t5zZ0fjWvvzdYPpbzAFGvj/p8N5v0MjeMWz+/+v+H9OgGAgRmzAWPJkgXxtX/++5g6dXK2auxYenXcfRh6Ng7Zi7fH7Q9E38Eh594H44GIrnU/0rvX47h3nRPzI+KBG64f/C/ouV/iH/h8XHprsYbykvjEj/p5nSV1DyMqp8cmIiIWxsJcu33NQ/GzFzPVQyp/CFTv5Tsf6upp6TqOveuLLiPxfAWAMaZq6fFndGYLB+Pty/46qqpq4oFnPhsdne3Z6mFlyZIF8bWvfjGmTZsSr7yyOq75yJ/H9u07s6sNYxfFrU9+Js6LR+L6U/toGF9yUzx149ldPRifzAxXytVF19CgDyTp4VgSn/jRV+OKXAN3IAb6GpZeHXd/+/KY391rULxh3+U9t6yIm87r7skp2A/drzcG+NwFcu95bdzxwSvjS0Ub9IewX/KVs4/yj2uv9zvUcudpQuXsAwCgqOqqmjhv2V9HZ2d7/PKZz2arkxlzAeOoo+bFHf9ya0ybNqWnbLiEjOOu/Wber7pDrZ+wUpZDaEgPuOF48Ln6Cxhd+zV6B4ClV8fd3z4n7s+UH3ftN+OjK68sb6hTd4O+v9fQFXKKh5DccX/ghgt7P2eZ28/JP4fKfUzl5AWMQwo7eefVgM8TACBHwKiQceNq4x++uDzOO/fMgvLhEjLKk6AHoyIKA0bRBnNCXY3pNX3vgz4cd+0343PxdwWN8IMN9OJhoFBfPSBdxyi698HQBIz8/d/9XEvyejRSKuucEjAAYDgZqoAx5uZgHDjQFp/+ixvjwQd/VVC+ZMmCuO2f/2Fsz8kYYV649co4pSBc9D3mP7t850NH9lyeNr+sS9e8gD4v9XrJh7qGRv1V7wbvcdd+MM6LiPNuHMj8iUPUM2F9OMy/yMhdzWxQyyB7xQCAw2LMBYyIiP37D8Sf/8VyIWPUuS+uPfXCOKXP5fNdk77XfC8+0Kuu91K61+CiuPXGs2PNnX/Xq0ci4qL4aN4wpcH/cj8w7/lI15yUiIg1v/hFV+i59/pe7ynJMkTvCQAYecZkwIi8kPHQQ0IGA/eeWz4T5635XvxlkQCS670ofVWpSjh4qdyIiPkf+pAb1wEAh8WYm4ORNX78uLj5i8vjnHPeWlA+rOdkLL06bn3XL+LaIWu85lTgqkCHNBn5orj1yXNjxYDmYHS/h0MYy19y0nhEye1Xeg5GrwsEFHt/eVfeGoyir69P5mAAwHBiDsYQ2b//QHzq08vjoYd/XVA+rHsyXrx90OHiuGu/WdYN6oaXUnMrPhPnxdlxU8q7TF9yUd+//F9yU3dDvtS9Gz4T50XxeRmVc3BIFgDA4TbmA0bkQsaf3zCsQsZ7bsk2XNMs+Y3jZI3yiis+t+IDd67tql7zvfjqgH5ZL+GSm+KpGz8TNz15U+mQce/1cX3Xnfsiun/V73lNNzzSXda7l6KS3nNL+l6l5EzyBoAxY8wPkco3fvy4uPkfboxz3nZGQflwHy5197cvj/llDUE5ONRk4MNdIjNEqvhwn3KluF9DbhvFHt9zc71B6+P9Lb06PrHk9vhSwf7r3jcljkPFhkjl31gv37AYSlSBIXXD4n0BwMhkiNRhsH//gfjzP18eDz/yeEH54ezJYHDu+WTvHo+updyrSPUOAj1ezIaLJfGJH3XPuygSLipm6dVxd364eOCRrvc2HD3w+SL7uNzlo3HHmuwGAYDhSsCAQ3TctX8bV8x/JK7v95f14vM2cj05593Yu65o70TOkvl5E7Yfies/+WBBdS9Lr467s9sfwDJyhtQBAIeTgJFn/Phx8Q//sDzedvbpBeXDeogUh9clN3VfUaqcK1mtjTs+mP11/uBckoL5HLmle15HUfden/fYcp4fAKDyBIxuI3L+BYdX9/yHPid1L10Sx2XLEnrh1ivjAx/8aO+5G8ONSd4AMGYIGMLFIBUf7lPuUnDPhmFnSXziR31cSWrpRXHrj7qGL625M69xf8lNvd7nUx9Z2M+wqUP3wotFJn8X8+LtcWm2h2QAy7APMQDAsDDmA0bPjfaECyLyrrR1dtz0o6tL9D4cHQu6f1Gf/6GvHgwTN57de5jTUE76Hs76nOT90fhAkaFj+fUmeQPAyDGmA8aIvIs3pZU1ibn7sqnzL4/v9KpbUTgcZ/7l8Z1i98R48fb4y9w9OKKw8Tx8f+UvdbPCAS63XJTdcB/y7l9SKmhdclM89eRX4zvfXhF3X7skW9vtlfjS73Vvp9+J9ADA4TZmA8b48ePiH/5euBi84hOWB7MUvb/DYJQ1BKjcy9TmluKTp1+49dvxQC5YlGo8c1B++MsLKcctPji5Yv6HvtpHyOjynltWxFMle5YAgOFgTAaMXLg491zhYmDy76jdx8TmrEsu6t0LEF2Nzlv7aVAO2NKr4+4haYDeF9cKFsUVzEX5ZnxiaWH1mtWrev7/hVuvPHhH9u6QUfxyuF09MDed10fPEgAwLIy5gDFuXG188e9vGOXhYkl84kdFhrZc8qFDuhrPcdd+s2d7BxuBeUNvMg3799zSXX7jZ+LqgiDR/ZhvXx7nlfGrdfkuilu/PT9u72sYzdKjY0G2rIKOW5rqvSXU53yIIssHvxcDmQKR3ysRD3y73yD6wq1XxvV5dwg878beoSTivri24JK9fc2RAQAOpzEXMGbPnhUnnlDYehld4SIKx6znhgSd95m8m7Y9EisGPFfgovho3pWfFizONZxXxapc63P+OfGuvF17z88PNgjnf+hDeb843xdfLetX64G4KG598jMRw+B+ED3B6skV8bl3ZWtHuyXxrnccPE8e+Hl5vTz3fLKMidz3Xl94X5D5l8d3BjQnBAAYCmMuYLz++rr4k49+OrZt2xExKsNFVtewpvxhKGvuvHPAjfDjrv1g1+ToiIhYG/f/rNi8iSNjYf4P9vc+2BVuIiLi7IJejBdu/buCBuV5Nx7KkJd3xK1PfibOe+DzQz/JusjE8gt/fvDX/2TzS0aKpe+I83s6MAYSZF+JL/3e5+OBNd+LD+QPv8sNt8r1VuTdXDCiKzin6wEDAFIYcwEjukPFn3z00/Hrx58a5eHioK5hKI/E9YNq9Bb2XhQOe3klVq4+WHWwZyMi4r5YkTf0Zf473pE3pOWV+NJf5Q+9GfyQl/kfujzOi0fi+qGYE5ENFN++POZHxJo7P9oTKoY85Awjx73rnOjJFw88OMAge19cmxne1jPcav7l8bnuIJEdUjX/Q39bZEgVAHC4jMmAEbmQ8ZG/GPnhYsn8gw26ftzzycEMH1oSn/hR96Vdu/U17GX+goUF/84fJpUdQhUv3h635zUUY/7l8dGyh0oVhp4HhmpoVOY15+570XdoWxKLF0TJmxPmbjp43o296w4Oa0tooHfV7g5R/csckxLnSfYcKS1/uFVhr9k9n+we+hfRtV8/YqgUAAwXYzZgUJ7jrv3bwonhAx2G9MqavF6KI+P8dxUOZylsKA5kqFTePI6BvqZDdM8nP99zidqBPe8gLu3bPeeg/Eb54VMwjG7N9+KrpfZN2QEn754ksSZWFkwWPzjp+4EbXCoYAIYTAWO0WHD0oIYXDcwghiG9+FrkjaAq0lA+GBS6hhmV3xPRM+xroK8pImL+/Bj8yP0RfonaCl1FKn9ezQNfz1zJ68VfxP3lbKSEoj1U914fpww45AEAlSZgjAJr7vxoxe5w/MKtV8Ypp3407lizNu74YJFGXr4134sP9Gp4r4pVd+Y1aHvV556jv2FGxQ1u2Ncjcf0Agkwqa+78u34v2VrMmjs/WnS/DT8Hr17Wu9HfVVcwQbsMuSFovbcHAAxXVUuPP6MzWzgYb1/211FVVRMPPPPZ6Ohsz1YDAACHUXVVTZy37K+js7M9fvnMZ7PVyejBAAAAkhEwAACAZAQMAAAgGQEDAABIRsAAAACSETAAAIBkBAwAACAZAQMAAEhGwAAAAJIRMAAAgGQEDAAAIBkBAwAASEbAAAAAkhEwAACAZAQMAAAgGQEDAABIJlnA6Ohsj4iI6urabBUAAHCY5drpuXZ7pSQLGPsP7I6IiLpxk7JVAADAYZZrp+fa7ZWSLGDsbd0aERET6o/IVgEAAIdZrp2ea7dXSrKA0bx3fURETJ44P1sFAAAcZrl2eq7dXinJAsb25lVxoG1vzJz8ppjUOC9bDQAAHCaTGufFzMlvigNte2N786psdVLJAsbOPa/H1l0vx/hxTTFr2ptN9gYAgGGguro2Zk17c4wf1xRbd70cO/e8nl0lqWQBIyJi47ZnYm/r1pg34/SYO/0t2WoAAGCIzZ3+lpg34/TY27o1Nm57JludXNKAsWP3a7F+y5PR3t4aC+ecH0fOPENPBgAAHAbV1bVx5MwzYuGc86O9vTXWb3kydux+LbtacjUzZs5bni08FLv2rovq6pqY3HhkzJj8pqitqY+2tn3ReqA5uyoAAFABkxrnxVFHnBULZp8XnR1t8frmx2LNG49kV6uIqqXHn9GZLUzhyJlvjbkzTo3Guumx/0BzbN75u9i5e03saXkjWg/sio6OtuxDAACAQaiuro26cZNiQv0RMXni/Jg5+U0xflxT7G3dGuu3PBlrN/8q+5CKqVjAiIiYMvHomD1tWUyfdEyMq23MVgMAABVwoG1vbN31cmzc9syQDIvKV9GAkTN5wlExtWlhNDXOjca66TF+3MSorqrJrgYAAAxCR2d77D+wO/a2bo3mvetje/Oqil8tqpQhCRgAAMDYkPQqUgAAwNgmYAAAAMkIGAAAQDICBgAAkIyAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAAQDJVS48/ozNbCAAcmsb66TGxYWY01E2J8eOaYlxtfVRV+V1vNOvs7IgDbS2x/0Bz7GvdEbv3bY69LVuzqw2JyROOiqlNC6OpcW401k2P8eMmRnVVTXY1RpGOzvbYf2B37G3dGs1718f25lWxc8/r2dWGhIABAAlNaJgRUybOj6YJs6O2eny2mjGkrWN/NO/ZGDt2r4k9+7ZkqytiysSjY/a0ZTF90jExrrYxW80YcqBtb2zd9XJs3PZM7Nj9Wra6ogQMAEhk+uTFMW3SwqgbNzFbxRjWemB3bNu1KrbuXJmtSurImW+NuTNOjca66dHWvid2tayKva0boqVtWxxo3x2dnW3ZhzCKVFXVxriaiVFfOy0a6+bEpPqFUVszIfa2bo31W56MtZt/lX1IxQgYAJDAEVOXxvTJS6KmujZbBdHe0RZbd74Sb2x/MVuVxILZ58VRM8+Mmpq62Lb7t7Fj38uxb/+m7GqMIQ3jZ8WUhmNi2sQ3R3t7a7y++bFYvfGB7GoVUTNj5rzl2UIAoHzTJy+OmVPeJFxQUnVVddTXTYnOzvbY17o9W31Ijpz51jh61jlRVV0dG3c9Glt2PxUH2puzqzHGtLXviT3710Vbx76YWH9UTGqcFx2dbbFr77rsqsmZbQYAh2BCw4yYNmmhcEG/aqprY9qkhTGhYUa2atCmTDw65s44NWpq6mLTrl/Ftt2/jc7O9uxqjFGdne2xbfdvY9OuX0VNTV3MnXFqTJl4dHa15AQMADgEUybON+eCstWNmxhTJs7PFg/a7GnLorFuemzb/dvYvuc/s9UQERHb9/xnbNv922ismx6zpy3LVicnYADAIDXWT4+mCbOzxdCnpgmzo7F+erZ4wCZPOCqmTzom2tr3xI59L+u5oKTOzvbYse/laGvfE9MnHROTJxyVXSUpAQMABmliw0yXomXAaqvHx8SGmdniAZvatDDG1TbGrpZVJnTTr337N8WullUxrrYxpjYtzFYnJWAAwCA11E3JFkFZUpw7TY1zIyJib+uGbBUUlTtXcudOpQgYADBI48c1ZYugLCnOnca6rmFWLW3bslVQVO5cyZ07lSJgAMAgjautzxZBWVKcO+O7Ly5woH13tgqKyp0ruXOnUgQMABikqipfowxOinOnuqomIsIduilb7lzJnTuVcuhnNwAAQDcBAwAASEbAAAAAkhEwAACAZAQMAAAgGQEDAABIRsAAAACSETAAAIBkBAwAACAZAQMAAEhGwAAAgKwLPh3/8dPvxX98/Q9jSbaOPgkYAMDgNT8Sv3r2kdiRLS9pV2x45Y741bN3xHObd2UrYfhb/Idx41VHZ0vJU7X0+DM6s4UAQP9OWHRpVEVVtniE2BUbXvlRrNl7TLzppLNjSrY6X/Mj8atVL2dLC0yY83tx4sxJ2eIics87kMeMPp3RGc+/ene2eEDevuyvo6qqJv5z/T9HZ2dHtnqYOidu/On/iLOyxf1aH//6p5+Kr6/MlpfvwuXfi+vOjHj07y+PG36eqbzg0/Eff3FaxGP/FO9c/lBh2bq742Mf/m680r1qbjsREWvv+kxc9Y3XejYz3FVVVcfxcz8SnZ3t8ctnPputTkYPBgCMRc3PxZq9EREvx++evSNebc6VPxK/erarh6Fn6SdcRETs2fDcAHoxYKidE+d2h4Kz/uJ78R/LzymovfDc07r+58z/ETdeUFBVaPEfxge6txOP/dOIChdDScAAgLGoaX7MzPvn5lVlDllqPC2WnXRFvLXX0k8vCERExENxw8WXxzsvvjy+8FhXydq7PhPv7C5758WXxzv//omuinV3x8d6yg+t9yL3vLnnjDP/R0HIWPF/7o613f9/1gdKz7m48P97aRwZERFPxBdyPR30ImAAwJh0ZCw66Yp407Tcv2fE9MmTIprOLhIerohlc2Z0rVY/OerztlIxxXpSiiw9PS+MUkfHh7/+va7J1v0tZUzGXrH88vjYXet7B4SV343P3rW+6//nnR5vX3ywqscFn84bGvX9WJGtp4eAAQBj2JQju0LGhDnnxpzx2dqDWlq3RETEhLqBzpk4OKm7a+mafxERsWfDj3oFhl+98ny0ZDcB5Zh3aXwlM/SpmFe+8al458Vf7BUQXvnl47G2r7keP/9iV+/Kurvjs4ZG9UnAAIAxbsqRV/Qz2XptbNsWB3s5htQx8aZePSq/F/Mbs+sxOr0WX/9wrtehyHCqiy+Pd/5pbnjT+vjX/3MIw5ZWfjeu6m8o1s+/GO/Mm/BNca4iBQCDNLKvItVby+afxDMbunoqBq3xtFi25IQ+hlGVuIpU7kpVucf3XLmq2FWuDm5j5sIrYlFTQeWIMHavInVQ/tWYisq7etOSq26Or1w2t/AqTzm5qz3FE/GFIj0TB69c1V/9ABV7LcOcq0gBABWSG7b0k9iwP1sHQ6tXr0RukneeV17rnh9x1FG95lksOXpu1/+sWxerM3UREXHBmd3h4bS47qc3x4eLza8gKQEDAMaankvUbok1L94Rv1rbNcCkfua7e03ufuvS02JCRETMiPlLu8sWHtO1nWJXlOqz96JQY/1QD7dixPr5Y/FoRMS8ebEgU7VgfnfAeP314kOXfv7FvGFUc+O/f7lUyFgf//qnmeFXRZbccC1KEzAAYKxpOjvv6lERse3nPSEja8cbT8SeiIjGBTE1Nwm86cSuORB7n4hXyrm0LRyy12PNuoiI0+LcgvtUHLy/xaMP9jFcaeV346pcyHjsh33Ps+CQCRgAMAZNOTKvJyIiZk7uurp/vpbNP4nfdU/unj8/v2diUsyZ1fXYPRt+NMBLxe6Kfd1XkYLyvRa/fLir5+Csc/OuFLX4qDgqIiLWx5qi46PyrPxuXHXx5X3Mm5gb//3LRS5/m1m+cll3jwklCRgAMFY1nd01BGraBb0nSjc/0jPhu+glbPN6QTavGsD9KPbvjK58MSMa6rKVjEVHXvb5wkb8X3TfVTujZx7GmWfGhd1lS95+eteN79Y9Hr/UKzFsCBgAMJaNPyFOPLKw96Jl80+6r+DUNc/iyHiw6x4VmWFUU448eLnYzat61xfVurNryFVMjYZsaIG+/Pz78a8Fw6SOjre/ras3Ye3DjxSffzEg5mCkImAAAN3WxqvP3nHwUrX9XnJ2UsxZkndPim0/j189e0c818e8jB07c8FliO4IzrBXzlWkuuQNk/rAH8aSC/4g/vu8iIgn4jsDuPHdhcu/F//x00/39IKQnoABAGNZ8yM9oaBl8zOxOVfeb7jImRRzlnTdDbxLXzfjy92wL2LC5KPK2DYUeuUbP+y+mtSl8ZXcUKrHHityb4sSFv9hfODM6LpkbRl3/WZwBAwAGMNyPQp7WndF/cxlMTMiJsy5IN40q3QA2LH2kdgRcfB+GmvXdk0aX3paTJi2rPd8jZzmNd0Bpq8QwljXc1+Loh6K/1MwRGkgd+8+Oj7815d2zdko+jiTvFMRMABgrNr/fKzt7lHouorUkbHopCvixPo18btVPypyI76dseGVO+J3216O361dG7H/9di6t2to1HObdxWdz3HQrtiwKTc8Ku+StxBnx40DaMD3TPaOiIj18Vq5k7t7hlS5VG2lCRgAMEa17FzdPeH6mJjWcxWpvCDQqzdics/laWPbM7EhToglc2ZERMSeDQ/2fVfwnpv7FR8e1dKyPVPCqHTBp3uCxHXd96848rJLu++0XYYLPp25ytRpcd3X/7DX3b17Oydu7HncE/GFopeqNck7FQEDAMaktbE+N5l72vyYkivuCQIzYv4RRXojcjfZiy2x5o21PcOqIrbEmjXPR0t2/YjC0BLHxJEzew+Pamntfi312cnfL8fvnr2j6ypWPcuPesIKI8zqdd131M5Yd3d8rL9J3gXh4on4Qm69eZfGV/qZtH3h8v/RE2LW3vX98udsMCgCBgCMQQcndOcHibXxau7ytL16L3IO3mQvtj0TG/YfGXO7ezFK3dl7x9qDgWDmwrMPhpme+ju6b+gXMaGud/hgFFn5SDy8rut/C64e9eHv9nmZ2SVX3VwYLi7+Yqz4+Rfzwshpcd1Pb44PL857UM4Fn+7pLYl1d8dnB3DFKQZHwACAsWb/8/FKz6VoD86H2LH250VCRxFN83t6LbbuPDg5PCJiz4bnuieAd2t+pCc8RONpMTd/KNYrXT0SPfVFezeOiTeddEW8tWDJuzQuI8xr8fUPd4WKq8pq6B8dH/56/ryM7nCRqy4IGXPjv385EzIW/2F8I29I1aPf6TvIkIaAAQBjyq7YsOaJ7rkXETNnHbwU7ZQjuy4323Pn7v3Px3P598XoMSkaGiNi2gVx4sxJEdHdi9F4Wiw7KdNDUTc5JkR0BYWCy95OioaCsVAzYv7S3r0bxXVdGvetJ13R+w7kjGKZcJHz8y/GO//07q6hV9nJ2yu/G1f11P1T3PDzvLoBWnLVzWVNQkfAAIAxZlLMWXJBV49DQY9ClylHXtEdGiJi/OTI7yg4OHypu4Gfd8Wo+pnvjrcWu2/G+BPixKUXxJuKhIcpR+b3Sry7cEhW09nd5b0fx1jyWnz9s3fH2nV3x8eKhYucld+Nqy6+PN5ZbPL2yu/GVRf/U/zrmtezNQNSePWqiEcfLPJcRERE1dLjz+jMFgIA/Tth0aVRFVXZ4hFiV2zYvCvmzOxjKBQV0xmd8fyrd2eLB+Tty/46qqpq4j/X/3N0dnZkq6GXqqrqOH7uR6Kzsz1++cxns9XJ6MEAgDFpknABVISAAQAAJCNgAAAAyQgYAABAMgIGAACQjIABAAAkI2AAAADJCBgAAEAyAgYAAJCMgAEAACQjYAAAAMkIGAAwSJ2dHdkiKEuKc6ejsz0iIqqqarNVUFTuXMmdO5UiYADAIB1oa8kWQVlSnDv7D+yOiIhxNROzVVBU7lzJnTuVImAAwCDtP9CcLYKypDh39rZujYiI+tpp2SooKneu5M6dShEwAGCQ9rXuyBZBWVKcO81710dERGPdnGwVFJU7V3LnTqUIGAAwSLv3bY62jv3ZYuhTW8f+2L1vc7Z4wLY3r4oDbXtjUv3CaBg/K1sNBRrGz4pJ9QvjQNve2N68KludlIABAIO0t2VrNO/ZmC2GPjXv2Rh7Ww59iMrOPa/H1l0vR23NhJjScExUVdVkV4GIiKiqqokpDcdEbc2E2Lrr5di55/XsKkkJGABwCHbsXhOtFZ4wyejRemB37Ni9Jls8aBu3PRN7W7fGtIlvjqkTjs9WQ0RETJ1wfEyb+ObY27o1Nm57JludnIABAIdgz74tsW3XqmjvaMtWQYH2jrbYtmtV7Nm3JVs1aDt2vxbrtzwZ7e2tMWvSW2PaxDfryaBHVVVNTJv45pg16a3R3t4a67c8GTt2v5ZdLbmaGTPnLc8WAgDl29e6PaqrqqO+bkpUV/ntjt7aO9pi685XYsuOl7NVh2zX3nVRXV0TkxrnxaSGRVFTNT7aO1ujrX1PdlXGkIbxs2LGxGVxxKTTo6OjLV7f/FiseeOR7GoVUbX0+DM6s4UAwMBNn7w4pk1aGHXj3JeAg1oP7I5tu1bF1p0rs1VJHTnzrTF3xqnRWDc92tr3xK6WVbG3dUO0tG2LA+27o7NTL9toVlVVG+NqJkZ97bRorJsTk+oXRm3NhNjbujXWb3ky1m7+VfYhFSNgAEBCExpmxJSJ86NpwuyorR6frWYMaevYH817NsaO3WuSDovqy5SJR8fsacti+qRjYlxtY7aaMeRA297Yuuvl2LjtmSEZFpVPwACACmisnx4TG2ZGQ92UGD+uKcbV1keV4VOjWmdnRxxoa4n9B5pjX+uO2L1vc5KrRQ3G5AlHxdSmhdHUODca66bH+HETo9rcjFGto7M99h/YHXtbt0bz3vWxvXlVxa8WVYqAAQAAJOOnFAAAIBkBAwAASEbAAAAAkhEwAACAZAQMAAAgGQEDAABIRsAAAACSETAAAIBkBAwAACAZAQMAAEhGwAAAAJIRMAAAgGQEDAAAIBkBAwAASEbAAAAAkqitrRYwAACANCZOqBMwAACAQ9fYOD7GjasRMAAAgENTW1sdExrHR5iDAQAAHKqJE+siImLfvhYBAwAAGJza2uqYMqUhxtXWRNuBtti48Y2obmzo6soAAAAoV2PD+Jg6pTHG1dbEgQNtsXHTG9HW3h7VExrHx5RJ9VFbqzMDAADoW21tdUyZVN8z52Lf3n2xdu362LevNSIiqs459/zOqqiKzuiMjo7OaGvriLb2jmhra4+2to7o6OjMbBIAABgLqqurora2Ompra6K2prrrPhfVVZHLD9u374itW7d3rVxVFRER/38ZuRvV2s8pHAAAAABJRU5ErkJggg==)\n\n完成这一双向 ID 交换后，Xeres 将直接建立安全连接，全程无需任何第三方服务器。\n\n注意：如果你只是想试用该软件或寻找新朋友，还可以使用在线 [聊天服务器](https://retroshare.ch/)。\n\n提示：你也可以使用二维码功能。用手机拍摄二维码，然后在添加好友窗口中点击二维码扫描按钮，将其展示给另一个 Xeres 实例。"
  },
  {
    "path": "ui/src/main/resources/help/zh/02.网络.md",
    "content": "# 连接\n\n只要你按照[快速设置](01.快速设置.md)中的说明添加了好友，与其他好友的连接就会自动建立。\n\n![](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAjwCPAAD/4QBmRXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAAExAAIAAAAQAAAATgAAAAAAAACPAAAAAQAAAI8AAAABUGFpbnQuTkVUIDUuMS45AP/bAEMAAgEBAQEBAgEBAQICAgICBAMCAgICBQQEAwQGBQYGBgUGBgYHCQgGBwkHBgYICwgJCgoKCgoGCAsMCwoMCQoKCv/bAEMBAgICAgICBQMDBQoHBgcKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCv/AABEIAd0CIQMBEgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP38ooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKKACigAooAKjaZw+xISecZPA6e/9KdieZXs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)\n\nXeres会自动寻找并连接对等好友。这个过程可能需要一点时间，从几秒到几分钟不等。\n\n请记住，你们双方都需要互相添加对方的ID，否则连接无法建立。\n\n已连接的好友数量也会显示在主窗口的左下角。同时还有DHT和NAT指示器，其功能将在下文描述。\n\n## 辅助功能\n\n为了在不断变化的动态网络中改善连接，以下功能可能会有所帮助。\n\n### DHT\n\nDHT是分布式哈希表（Distributed Hash Table）的缩写，它是一个系统，帮助两个对等点在IP地址发生变化或未知时找到对方。Xeres使用的是BitTorrent DHT，也称为Mainline DHT。如果该LED指示灯不是绿色的，那么连接到好友可能会更困难。\n\n### NAT\n\n如果你位于NAT（网络地址转换，大多数路由器都配置了NAT）之后，那么传入连接可能会受到限制。Xeres尝试通过使用UPNP协议来解决这个问题。请确保在你的路由器上启用了UPNP。与普遍看法相反，如今UPNP是安全的，因为所有旧的漏洞都已被修复。Xeres中的NAT LED指示灯应为绿色。"
  },
  {
    "path": "ui/src/main/resources/help/zh/04.表情符号.md",
    "content": "# 表情符号\n\n可以使用别名快速显示一些表情符号。更推荐直接使用操作系统快捷键输入（例如 Windows 系统按 Win+. 键）。\n\n### 常用表情\n\n:joy​: :joy:\n\n:grin​: :grin:\n\n:rofl​: :rofl:\n\n:yum​: :yum:\n\n:blush​: :blush:\n\n:rage​: :rage:\n\n:scream​: :scream:\n\n:cry​: :cry:\n\n:sob​: :sob:\n\n:sick​: :sick:\n\n:poop​: :poop:\n\n:muscle​: :muscle:\n\n:wave​: :wave:\n\n:eyes​: :eyes:\n\n:zzz​: :zzz:\n\n:fire​: :fire:\n\n:heart​: :heart:\n\n:boom​: :boom:\n\n### 国家/地区\n\n:cc​: （使用国家/地区的互联网域名代码，例如 ch 代表瑞士，fr 代表法国等）\n"
  },
  {
    "path": "ui/src/main/resources/help/zh/05.启动参数.md",
    "content": "# 启动参数\n\n当手动运行Xeres时，您可以使用以下命令选项。这些仅用于高级用法，通常不需要使用。\n\n- `--no-gui`：无界面启动。可用于在无头模式下运行Xeres。配合`--remote-connect`参数使用另一个实例来连接它。\n- `--iconified`：启动时最小化到系统托盘。这对自动启动很有用。\n- `--data-dir=<路径>`：指定数据目录。这是Xeres存储所有用户文件的位置。如果要运行多个实例，每个实例都需要有不同的数据目录。\n- `--control-address=<主机>`：指定用于接收远程访问的绑定地址（默认为仅本地主机）。\n- `--control-port=<端口>`：指定用于远程访问的控制端口。这是UI将连接的端口。如果要运行多个实例，每个实例都需要有不同的控制端口，但Xeres会自动尝试寻找空闲端口（从1066开始），因此这个参数很少需要使用。\n- `--no-control-password`：不对控制地址进行密码保护。密码在首次运行时自动生成，并可在设置中查看。密码可以更改或禁用。\n- `--server-address=<主机>`：指定要绑定的本地地址（如果未指定，则绑定到所有接口）。\n- `--server-port=<端口>`：指定用于接收传入连接的本地端口。默认情况下，Xeres会随机选择一个端口并在同一实例中永久使用。\n- `--fast-shutdown`：忽略正常的关闭程序。这主要用于需要快速运行/关闭Xeres实例的测试场景。正常使用时不需要。\n- `--server-only`：仅接受传入连接，不建立传出连接。主要用于聊天服务器。\n- `--remote-connect:<主机>[:<端口>]`：作为UI客户端启动并连接到指定节点。您也可以在局域网上跨机器执行此操作。请注意，该连接未加密。如要克服这一限制，请使用SSH隧道。\n- `--remote-password=<密码>`：远程连接时使用的密码\n- `--version`：打印软件版本\n- `--help`：打印帮助信息"
  },
  {
    "path": "ui/src/main/resources/help/zh/06.链接",
    "content": "# 实用在线链接\n\n## Xeres\n\n- [主页](https://xeres.io)\n- [新闻动态](https://xeres.io/news)\n- [文档与常见问题解答](https://xeres.io/docs)\n- [在线讨论](https://github.com/zapek/Xeres/discussions)\n- [开发路线图](https://github.com/users/zapek/projects/4)\n- [问题追踪](https://github.com/zapek/Xeres/issues)\n- [维基百科](https://github.com/zapek/Xeres/wiki)\n- [GitHub 项目页面](https://github.com/zapek/Xeres)\n\n## 第三方资源\n\n- [聊天服务器](https://retroshare.ch)：如果您没有可连接的好友，可以使用此在线服务器。由 Xeres 作者创建并维护。\n- [Retroshare](https://retroshare.cc)：这一切的开源项目。Xeres 与之兼容。\n- [网络拓扑结构](https://retroshare.readthedocs.io/en/latest/concept/topology/)：关于 Retroshare 和 Xeres 所使用网络拓扑结构的精彩介绍。"
  },
  {
    "path": "ui/src/main/resources/oembed-providers.json",
    "content": "[\n    {\n        \"provider_name\" : \"Audiomack\",\n        \"provider_url\" : \"https://audiomack.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://audiomack.com/*/song/*\",\n                    \"https://audiomack.com/*/album/*\",\n                    \"https://audiomack.com/*/playlist/*\"\n                ],\n                \"url\" : \"https://audiomack.com/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Bluesky Social\",\n        \"provider_url\" : \"https://bsky.app\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://bsky.app/profile/*/post/*\"\n                ],\n                \"url\" : \"https://embed.bsky.app/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Dailymotion\",\n        \"provider_url\" : \"https://www.dailymotion.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://www.dailymotion.com/video/*\",\n                    \"https://geo.dailymotion.com/player.html?video=*\"\n                ],\n                \"url\" : \"https://www.dailymotion.com/services/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Deviantart.com\",\n        \"provider_url\" : \"https://www.deviantart.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://*.deviantart.com/art/*\",\n                    \"https://*.deviantart.com/*/art/*\",\n                    \"https://sta.sh/*\",\n                    \"https://*.deviantart.com/*#/d*\"\n                ],\n                \"url\" : \"https://backend.deviantart.com/oembed\"\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Flickr\",\n        \"provider_url\" : \"https://www.flickr.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://*.flickr.com/photos/*\",\n                    \"https://flic.kr/p/*\",\n                    \"https://flic.kr/s/*\",\n                    \"https://*.*.flickr.com/*/*\"\n                ],\n                \"url\" : \"https://www.flickr.com/services/oembed/\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"GIPHY\",\n        \"provider_url\" : \"https://giphy.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://giphy.com/gifs/*\",\n                    \"https://giphy.com/clips/*\",\n                    \"https://media.giphy.com/media/*/giphy.gif\"\n                ],\n                \"url\" : \"https://giphy.com/services/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Odysee\",\n        \"provider_url\" : \"https://odysee.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://odysee.com/*/*\",\n                    \"https://odysee.com/*\"\n                ],\n                \"url\" : \"https://odysee.com/$/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"PeerTube.TV\",\n        \"provider_url\" : \"https://peertube.tv/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://peertube.tv/w/*\"\n                ],\n                \"url\" : \"https://peertube.tv/services/oembed\"\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Pinterest\",\n        \"provider_url\" : \"https://www.pinterest.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://www.pinterest.com/*\"\n                ],\n                \"url\" : \"https://www.pinterest.com/oembed.json\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Reddit\",\n        \"provider_url\" : \"https://reddit.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://reddit.com/r/*/comments/*/*\",\n                    \"https://www.reddit.com/r/*/comments/*/*\"\n                ],\n                \"url\" : \"https://www.reddit.com/oembed\"\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"SoundCloud\",\n        \"provider_url\" : \"https://soundcloud.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://soundcloud.com/*\",\n                    \"https://on.soundcloud.com/*\",\n                    \"https://soundcloud.app.goog.gl/*\"\n                ],\n                \"url\" : \"https://soundcloud.com/oembed\"\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Spotify\",\n        \"provider_url\" : \"https://spotify.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://open.spotify.com/*\",\n                    \"https://spotify.link/*\"\n                ],\n                \"url\" : \"https://open.spotify.com/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"TED\",\n        \"provider_url\" : \"https://www.ted.com\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://ted.com/talks/*\",\n                    \"https://www.ted.com/talks/*\"\n                ],\n                \"url\" : \"https://www.ted.com/services/v1/oembed.json\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"TikTok\",\n        \"provider_url\" : \"https://www.tiktok.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://www.tiktok.com/*\",\n                    \"https://www.tiktok.com/*/video/*\"\n                ],\n                \"url\" : \"https://www.tiktok.com/oembed\"\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"Vimeo\",\n        \"provider_url\" : \"https://vimeo.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://vimeo.com/*\",\n                    \"https://vimeo.com/album/*/video/*\",\n                    \"https://vimeo.com/channels/*/*\",\n                    \"https://vimeo.com/groups/*/videos/*\",\n                    \"https://vimeo.com/ondemand/*/*\",\n                    \"https://player.vimeo.com/video/*\",\n                    \"https://vimeo.com/event/*/*\"\n                ],\n                \"url\" : \"https://vimeo.com/api/oembed.json\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"WordPress.com\",\n        \"provider_url\" : \"https://wordpress.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://wordpress.com/*\",\n                    \"https://*.wordpress.com/*\",\n                    \"https://*.*.wordpress.com/*\",\n                    \"https://wp.me/*\"\n                ],\n                \"url\" : \"https://public-api.wordpress.com/oembed/\",\n                \"discovery\" : true\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"X\",\n        \"provider_url\" : \"https://www.x.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://x.com/*\",\n                    \"https://x.com/*/status/*\",\n                    \"https://*.x.com/*/status/*\"\n                ],\n                \"url\" : \"https://publish.x.com/oembed\"\n            }\n        ]\n    },\n    {\n        \"provider_name\" : \"YouTube\",\n        \"provider_url\" : \"https://www.youtube.com/\",\n        \"endpoints\" : [\n            {\n                \"schemes\" : [\n                    \"https://*.youtube.com/watch*\",\n                    \"https://*.youtube.com/v/*\",\n                    \"https://youtu.be/*\",\n                    \"https://*.youtube.com/playlist?list=*\",\n                    \"https://youtube.com/playlist?list=*\",\n                    \"https://*.youtube.com/shorts*\",\n                    \"https://youtube.com/shorts*\",\n                    \"https://*.youtube.com/embed/*\",\n                    \"https://*.youtube.com/live*\",\n                    \"https://youtube.com/live*\"\n                ],\n                \"url\" : \"https://www.youtube.com/oembed\",\n                \"discovery\" : true\n            }\n        ]\n    }\n]"
  },
  {
    "path": "ui/src/main/resources/retroshare-emojis.json",
    "content": "[\n    {\n        \"alias\" : \"grinning\",\n        \"unicode\" : \"1f600\"\n    },\n    {\n        \"alias\" : \"smiley\",\n        \"unicode\" : \"1f603\"\n    },\n    {\n        \"alias\" : \"smile\",\n        \"unicode\" : \"1f604\"\n    },\n    {\n        \"alias\" : \"grin\",\n        \"unicode\" : \"1f601\"\n    },\n    {\n        \"alias\" : \"laughing\",\n        \"unicode\" : \"1f606\"\n    },\n    {\n        \"alias\" : \"sweat_smile\",\n        \"unicode\" : \"1f605\"\n    },\n    {\n        \"alias\" : \"joy\",\n        \"unicode\" : \"1f602\"\n    },\n    {\n        \"alias\" : \"rofl\",\n        \"unicode\" : \"1f923\"\n    },\n    {\n        \"alias\" : \"blush\",\n        \"unicode\" : \"1f60a\"\n    },\n    {\n        \"alias\" : \"relaxed\",\n        \"unicode\" : \"263a\"\n    },\n    {\n        \"alias\" : \"innocent\",\n        \"unicode\" : \"1f607\"\n    },\n    {\n        \"alias\" : \"slight_smile\",\n        \"unicode\" : \"1f642\"\n    },\n    {\n        \"alias\" : \"updside_down_face\",\n        \"unicode\" : \"1f643\"\n    },\n    {\n        \"alias\" : \"wink\",\n        \"unicode\" : \"1f609\"\n    },\n    {\n        \"alias\" : \"relieved\",\n        \"unicode\" : \"1f60c\"\n    },\n    {\n        \"alias\" : \"heart_eyes\",\n        \"unicode\" : \"1f60d\"\n    },\n    {\n        \"alias\" : \"kissing_heart\",\n        \"unicode\" : \"1f618\"\n    },\n    {\n        \"alias\" : \"kissing\",\n        \"unicode\" : \"1f617\"\n    },\n    {\n        \"alias\" : \"kissing_smiling_eyes\",\n        \"unicode\" : \"1f619\"\n    },\n    {\n        \"alias\" : \"kissing_closed_eyes\",\n        \"unicode\" : \"1f61a\"\n    },\n    {\n        \"alias\" : \"yum\",\n        \"unicode\" : \"1f60b\"\n    },\n    {\n        \"alias\" : \"stuck_out_tongue\",\n        \"unicode\" : \"1f61b\"\n    },\n    {\n        \"alias\" : \"stuck_out_tongue_winking_eye\",\n        \"unicode\" : \"1f61c\"\n    },\n    {\n        \"alias\" : \"stuck_out_tongue_closed_eyes\",\n        \"unicode\" : \"1f61d\"\n    },\n    {\n        \"alias\" : \"money_mouth_face\",\n        \"unicode\" : \"1f911\"\n    },\n    {\n        \"alias\" : \"hugging\",\n        \"unicode\" : \"1f917\"\n    },\n    {\n        \"alias\" : \"nerd_face\",\n        \"unicode\" : \"1f913\"\n    },\n    {\n        \"alias\" : \"sunglasses\",\n        \"unicode\" : \"1f60e\"\n    },\n    {\n        \"alias\" : \"smirk\",\n        \"unicode\" : \"1f60f\"\n    },\n    {\n        \"alias\" : \"unamused\",\n        \"unicode\" : \"1f612\"\n    },\n    {\n        \"alias\" : \"disappointed\",\n        \"unicode\" : \"1f61e\"\n    },\n    {\n        \"alias\" : \"pensive\",\n        \"unicode\" : \"1f614\"\n    },\n    {\n        \"alias\" : \"worried\",\n        \"unicode\" : \"1f61f\"\n    },\n    {\n        \"alias\" : \"confused\",\n        \"unicode\" : \"1f615\"\n    },\n    {\n        \"alias\" : \"slight_frown\",\n        \"unicode\" : \"1f641\"\n    },\n    {\n        \"alias\" : \"frowning2\",\n        \"unicode\" : \"2639\"\n    },\n    {\n        \"alias\" : \"persevere\",\n        \"unicode\" : \"1f623\"\n    },\n    {\n        \"alias\" : \"confounded\",\n        \"unicode\" : \"1f616\"\n    },\n    {\n        \"alias\" : \"tired_face\",\n        \"unicode\" : \"1f62b\"\n    },\n    {\n        \"alias\" : \"weary\",\n        \"unicode\" : \"1f629\"\n    },\n    {\n        \"alias\" : \"triumph\",\n        \"unicode\" : \"1f624\"\n    },\n    {\n        \"alias\" : \"angry\",\n        \"unicode\" : \"1f620\"\n    },\n    {\n        \"alias\" : \"rage\",\n        \"unicode\" : \"1f621\"\n    },\n    {\n        \"alias\" : \"no_mouth\",\n        \"unicode\" : \"1f636\"\n    },\n    {\n        \"alias\" : \"neutral_face\",\n        \"unicode\" : \"1f610\"\n    },\n    {\n        \"alias\" : \"expressionless\",\n        \"unicode\" : \"1f611\"\n    },\n    {\n        \"alias\" : \"hushed\",\n        \"unicode\" : \"1f62f\"\n    },\n    {\n        \"alias\" : \"frowning\",\n        \"unicode\" : \"1f626\"\n    },\n    {\n        \"alias\" : \"anguished\",\n        \"unicode\" : \"1f627\"\n    },\n    {\n        \"alias\" : \"open_mouth\",\n        \"unicode\" : \"1f62e\"\n    },\n    {\n        \"alias\" : \"astonished\",\n        \"unicode\" : \"1f632\"\n    },\n    {\n        \"alias\" : \"dizzy_face\",\n        \"unicode\" : \"1f635\"\n    },\n    {\n        \"alias\" : \"flushed\",\n        \"unicode\" : \"1f633\"\n    },\n    {\n        \"alias\" : \"scream\",\n        \"unicode\" : \"1f631\"\n    },\n    {\n        \"alias\" : \"fearful\",\n        \"unicode\" : \"1f628\"\n    },\n    {\n        \"alias\" : \"cold_sweat\",\n        \"unicode\" : \"1f630\"\n    },\n    {\n        \"alias\" : \"cry\",\n        \"unicode\" : \"1f622\"\n    },\n    {\n        \"alias\" : \"disappointed_relieved\",\n        \"unicode\" : \"1f625\"\n    },\n    {\n        \"alias\" : \"drooling_face\",\n        \"unicode\" : \"1f924\"\n    },\n    {\n        \"alias\" : \"sob\",\n        \"unicode\" : \"1f62d\"\n    },\n    {\n        \"alias\" : \"sweat\",\n        \"unicode\" : \"1f613\"\n    },\n    {\n        \"alias\" : \"sleepy\",\n        \"unicode\" : \"1f62a\"\n    },\n    {\n        \"alias\" : \"sleeping\",\n        \"unicode\" : \"1f634\"\n    },\n    {\n        \"alias\" : \"rolling_eyes\",\n        \"unicode\" : \"1f644\"\n    },\n    {\n        \"alias\" : \"thinking_face\",\n        \"unicode\" : \"1f914\"\n    },\n    {\n        \"alias\" : \"lying_face\",\n        \"unicode\" : \"1f925\"\n    },\n    {\n        \"alias\" : \"grimacing\",\n        \"unicode\" : \"1f62c\"\n    },\n    {\n        \"alias\" : \"zipper_mouth_face\",\n        \"unicode\" : \"1f910\"\n    },\n    {\n        \"alias\" : \"sick\",\n        \"unicode\" : \"1f922\"\n    },\n    {\n        \"alias\" : \"sneeze\",\n        \"unicode\" : \"1f927\"\n    },\n    {\n        \"alias\" : \"mask\",\n        \"unicode\" : \"1f637\"\n    },\n    {\n        \"alias\" : \"face_with_thermometer\",\n        \"unicode\" : \"1f912\"\n    },\n    {\n        \"alias\" : \"head_bandage\",\n        \"unicode\" : \"1f915\"\n    },\n    {\n        \"alias\" : \"smiling_imp\",\n        \"unicode\" : \"1f608\"\n    },\n    {\n        \"alias\" : \"imp\",\n        \"unicode\" : \"1f47f\"\n    },\n    {\n        \"alias\" : \"japanese_ogre\",\n        \"unicode\" : \"1f479\"\n    },\n    {\n        \"alias\" : \"japanese_goblin\",\n        \"unicode\" : \"1f47a\"\n    },\n    {\n        \"alias\" : \"ghost\",\n        \"unicode\" : \"1f47b\"\n    },\n    {\n        \"alias\" : \"skull\",\n        \"unicode\" : \"1f480\"\n    },\n    {\n        \"alias\" : \"skull_crossbones\",\n        \"unicode\" : \"2620\"\n    },\n    {\n        \"alias\" : \"alien\",\n        \"unicode\" : \"1f47d\"\n    },\n    {\n        \"alias\" : \"space_invader\",\n        \"unicode\" : \"1f47e\"\n    },\n    {\n        \"alias\" : \"robot_face\",\n        \"unicode\" : \"1f916\"\n    },\n    {\n        \"alias\" : \"jack_o_lantern\",\n        \"unicode\" : \"1f383\"\n    },\n    {\n        \"alias\" : \"poop\",\n        \"unicode\" : \"1f4a9\"\n    },\n    {\n        \"alias\" : \"smile_cat\",\n        \"unicode\" : \"1f638\"\n    },\n    {\n        \"alias\" : \"joy_cat\",\n        \"unicode\" : \"1f639\"\n    },\n    {\n        \"alias\" : \"smiley_cat\",\n        \"unicode\" : \"1f63a\"\n    },\n    {\n        \"alias\" : \"heart_eyes_cat\",\n        \"unicode\" : \"1f63b\"\n    },\n    {\n        \"alias\" : \"smirk_cat\",\n        \"unicode\" : \"1f63c\"\n    },\n    {\n        \"alias\" : \"kissing_cat\",\n        \"unicode\" : \"1f63d\"\n    },\n    {\n        \"alias\" : \"pouting_cat\",\n        \"unicode\" : \"1f63e\"\n    },\n    {\n        \"alias\" : \"crying_cat_face\",\n        \"unicode\" : \"1f63f\"\n    },\n    {\n        \"alias\" : \"scream_cat\",\n        \"unicode\" : \"1f640\"\n    },\n    {\n        \"alias\" : \"boy\",\n        \"unicode\" : \"1f466\"\n    },\n    {\n        \"alias\" : \"girl\",\n        \"unicode\" : \"1f467\"\n    },\n    {\n        \"alias\" : \"man\",\n        \"unicode\" : \"1f468\"\n    },\n    {\n        \"alias\" : \"woman\",\n        \"unicode\" : \"1f469\"\n    },\n    {\n        \"alias\" : \"older_man\",\n        \"unicode\" : \"1f474\"\n    },\n    {\n        \"alias\" : \"older_woman\",\n        \"unicode\" : \"1f475\"\n    },\n    {\n        \"alias\" : \"baby\",\n        \"unicode\" : \"1f476\"\n    },\n    {\n        \"alias\" : \"angel\",\n        \"unicode\" : \"1f47c\"\n    },\n    {\n        \"alias\" : \"cop\",\n        \"unicode\" : \"1f46e\"\n    },\n    {\n        \"alias\" : \"spy\",\n        \"unicode\" : \"1f575\"\n    },\n    {\n        \"alias\" : \"guardsman\",\n        \"unicode\" : \"1f482\"\n    },\n    {\n        \"alias\" : \"construction_worker\",\n        \"unicode\" : \"1f477\"\n    },\n    {\n        \"alias\" : \"man_with_turban\",\n        \"unicode\" : \"1f473\"\n    },\n    {\n        \"alias\" : \"person_with_blond_hair\",\n        \"unicode\" : \"1f471\"\n    },\n    {\n        \"alias\" : \"santa\",\n        \"unicode\" : \"1f385\"\n    },\n    {\n        \"alias\" : \"princess\",\n        \"unicode\" : \"1f478\"\n    },\n    {\n        \"alias\" : \"bride_with_veil\",\n        \"unicode\" : \"1f470\"\n    },\n    {\n        \"alias\" : \"man_with_gua_pi_mao\",\n        \"unicode\" : \"1f472\"\n    },\n    {\n        \"alias\" : \"levitate\",\n        \"unicode\" : \"1f574\"\n    },\n    {\n        \"alias\" : \"dancer\",\n        \"unicode\" : \"1f483\"\n    },\n    {\n        \"alias\" : \"bust_in_silhouette\",\n        \"unicode\" : \"1f464\"\n    },\n    {\n        \"alias\" : \"busts_in_silhouette\",\n        \"unicode\" : \"1f465\"\n    },\n    {\n        \"alias\" : \"family\",\n        \"unicode\" : \"1f46a\"\n    },\n    {\n        \"alias\" : \"couple\",\n        \"unicode\" : \"1f46b\"\n    },\n    {\n        \"alias\" : \"dancers\",\n        \"unicode\" : \"1f46f\"\n    },\n    {\n        \"alias\" : \"bow\",\n        \"unicode\" : \"1f647\"\n    },\n    {\n        \"alias\" : \"information_desk_person\",\n        \"unicode\" : \"1f481\"\n    },\n    {\n        \"alias\" : \"couplekiss\",\n        \"unicode\" : \"1f48f\"\n    },\n    {\n        \"alias\" : \"couple_with_heart\",\n        \"unicode\" : \"1f491\"\n    },\n    {\n        \"alias\" : \"muscle\",\n        \"unicode\" : \"1f4aa\"\n    },\n    {\n        \"alias\" : \"finger_pointing_left\",\n        \"unicode\" : \"1f448\"\n    },\n    {\n        \"alias\" : \"finger_pointing_right\",\n        \"unicode\" : \"1f449\"\n    },\n    {\n        \"alias\" : \"finger_pointing_down\",\n        \"unicode\" : \"1f447\"\n    },\n    {\n        \"alias\" : \"finger_pointing_up\",\n        \"unicode\" : \"1f446\"\n    },\n    {\n        \"alias\" : \"point_up\",\n        \"unicode\" : \"261d\"\n    },\n    {\n        \"alias\" : \"middle_finger\",\n        \"unicode\" : \"1f595\"\n    },\n    {\n        \"alias\" : \"v\",\n        \"unicode\" : \"270c-fe0f\"\n    },\n    {\n        \"alias\" : \"vulcan\",\n        \"unicode\" : \"1f596\"\n    },\n    {\n        \"alias\" : \"raised_hand\",\n        \"unicode\" : \"270b\"\n    },\n    {\n        \"alias\" : \"ok_hand\",\n        \"unicode\" : \"1f44c\"\n    },\n    {\n        \"alias\" : \"thumbsup\",\n        \"unicode\" : \"1f44d\"\n    },\n    {\n        \"alias\" : \"thumbsdown\",\n        \"unicode\" : \"1f44e\"\n    },\n    {\n        \"alias\" : \"fist\",\n        \"unicode\" : \"270a\"\n    },\n    {\n        \"alias\" : \"punch\",\n        \"unicode\" : \"1f44a\"\n    },\n    {\n        \"alias\" : \"wave\",\n        \"unicode\" : \"1f44b\"\n    },\n    {\n        \"alias\" : \"clap\",\n        \"unicode\" : \"1f44f\"\n    },\n    {\n        \"alias\" : \"open_hands\",\n        \"unicode\" : \"1f450\"\n    },\n    {\n        \"alias\" : \"raised_hands\",\n        \"unicode\" : \"1f64c\"\n    },\n    {\n        \"alias\" : \"nail_care\",\n        \"unicode\" : \"1f485\"\n    },\n    {\n        \"alias\" : \"ear\",\n        \"unicode\" : \"1f442\"\n    },\n    {\n        \"alias\" : \"nose\",\n        \"unicode\" : \"1f443\"\n    },\n    {\n        \"alias\" : \"footprints\",\n        \"unicode\" : \"1f463\"\n    },\n    {\n        \"alias\" : \"eyes\",\n        \"unicode\" : \"1f440\"\n    },\n    {\n        \"alias\" : \"eye\",\n        \"unicode\" : \"1f441-fe0f\"\n    },\n    {\n        \"alias\" : \"tongue\",\n        \"unicode\" : \"1f445\"\n    },\n    {\n        \"alias\" : \"lips\",\n        \"unicode\" : \"1f444\"\n    },\n    {\n        \"alias\" : \"lips2\",\n        \"unicode\" : \"1f5e2\"\n    },\n    {\n        \"alias\" : \"kiss\",\n        \"unicode\" : \"1f48b\"\n    },\n    {\n        \"alias\" : \"zzz\",\n        \"unicode\" : \"1f4a4\"\n    },\n    {\n        \"alias\" : \"eyeglasses\",\n        \"unicode\" : \"1f453\"\n    },\n    {\n        \"alias\" : \"crown\",\n        \"unicode\" : \"1f451\"\n    },\n    {\n        \"alias\" : \"womans_hat\",\n        \"unicode\" : \"1f452\"\n    },\n    {\n        \"alias\" : \"necktie\",\n        \"unicode\" : \"1f454\"\n    },\n    {\n        \"alias\" : \"shirt\",\n        \"unicode\" : \"1f455\"\n    },\n    {\n        \"alias\" : \"jeans\",\n        \"unicode\" : \"1f456\"\n    },\n    {\n        \"alias\" : \"dress\",\n        \"unicode\" : \"1f457\"\n    },\n    {\n        \"alias\" : \"kimono\",\n        \"unicode\" : \"1f458\"\n    },\n    {\n        \"alias\" : \"bikini\",\n        \"unicode\" : \"1f459\"\n    },\n    {\n        \"alias\" : \"high_heel\",\n        \"unicode\" : \"1f460\"\n    },\n    {\n        \"alias\" : \"sandal\",\n        \"unicode\" : \"1f461\"\n    },\n    {\n        \"alias\" : \"boot\",\n        \"unicode\" : \"1f462\"\n    },\n    {\n        \"alias\" : \"lipstick\",\n        \"unicode\" : \"1f484\"\n    },\n    {\n        \"alias\" : \"ring\",\n        \"unicode\" : \"1f48d\"\n    },\n    {\n        \"alias\" : \"closed_umbrella\",\n        \"unicode\" : \"1f302\"\n    },\n    {\n        \"alias\" : \"star\",\n        \"unicode\" : \"2b50\"\n    },\n    {\n        \"alias\" : \"grapes\",\n        \"unicode\" : \"1f347\"\n    },\n    {\n        \"alias\" : \"melon\",\n        \"unicode\" : \"1f348\"\n    },\n    {\n        \"alias\" : \"watermelon\",\n        \"unicode\" : \"1f349\"\n    },\n    {\n        \"alias\" : \"tangerine\",\n        \"unicode\" : \"1f34a\"\n    },\n    {\n        \"alias\" : \"lemon\",\n        \"unicode\" : \"1f34b\"\n    },\n    {\n        \"alias\" : \"banana\",\n        \"unicode\" : \"1f34c\"\n    },\n    {\n        \"alias\" : \"apple\",\n        \"unicode\" : \"1f34e\"\n    },\n    {\n        \"alias\" : \"green_apple\",\n        \"unicode\" : \"1f34f\"\n    },\n    {\n        \"alias\" : \"pear\",\n        \"unicode\" : \"1f350\"\n    },\n    {\n        \"alias\" : \"peach\",\n        \"unicode\" : \"1f351\"\n    },\n    {\n        \"alias\" : \"cherries\",\n        \"unicode\" : \"1f352\"\n    },\n    {\n        \"alias\" : \"strawberry\",\n        \"unicode\" : \"1f353\"\n    },\n    {\n        \"alias\" : \"tomato\",\n        \"unicode\" : \"1f345\"\n    },\n    {\n        \"alias\" : \"aubergine\",\n        \"unicode\" : \"1f346\"\n    },\n    {\n        \"alias\" : \"corn\",\n        \"unicode\" : \"1f33d\"\n    },\n    {\n        \"alias\" : \"hot_pepper\",\n        \"unicode\" : \"1f336\"\n    },\n    {\n        \"alias\" : \"bread\",\n        \"unicode\" : \"1f35e\"\n    },\n    {\n        \"alias\" : \"meat_on_bone\",\n        \"unicode\" : \"1f356\"\n    },\n    {\n        \"alias\" : \"poultry_leg\",\n        \"unicode\" : \"1f357\"\n    },\n    {\n        \"alias\" : \"hamburger\",\n        \"unicode\" : \"1f354\"\n    },\n    {\n        \"alias\" : \"fries\",\n        \"unicode\" : \"1f35f\"\n    },\n    {\n        \"alias\" : \"pizza\",\n        \"unicode\" : \"1f355\"\n    },\n    {\n        \"alias\" : \"hotdog\",\n        \"unicode\" : \"1f32d\"\n    },\n    {\n        \"alias\" : \"taco\",\n        \"unicode\" : \"1f32e\"\n    },\n    {\n        \"alias\" : \"burrito\",\n        \"unicode\" : \"1f32f\"\n    },\n    {\n        \"alias\" : \"egg\",\n        \"unicode\" : \"1f373\"\n    },\n    {\n        \"alias\" : \"stew\",\n        \"unicode\" : \"1f372\"\n    },\n    {\n        \"alias\" : \"sweet_potato\",\n        \"unicode\" : \"1f360\"\n    },\n    {\n        \"alias\" : \"dango\",\n        \"unicode\" : \"1f361\"\n    },\n    {\n        \"alias\" : \"oden\",\n        \"unicode\" : \"1f362\"\n    },\n    {\n        \"alias\" : \"sushi\",\n        \"unicode\" : \"1f363\"\n    },\n    {\n        \"alias\" : \"fried_shrimp\",\n        \"unicode\" : \"1f364\"\n    },\n    {\n        \"alias\" : \"fish_cake\",\n        \"unicode\" : \"1f365\"\n    },\n    {\n        \"alias\" : \"bento\",\n        \"unicode\" : \"1f371\"\n    },\n    {\n        \"alias\" : \"rice_cracker\",\n        \"unicode\" : \"1f358\"\n    },\n    {\n        \"alias\" : \"rice_ball\",\n        \"unicode\" : \"1f359\"\n    },\n    {\n        \"alias\" : \"rice\",\n        \"unicode\" : \"1f35a\"\n    },\n    {\n        \"alias\" : \"curry\",\n        \"unicode\" : \"1f35b\"\n    },\n    {\n        \"alias\" : \"ramen\",\n        \"unicode\" : \"1f35c\"\n    },\n    {\n        \"alias\" : \"spaghetti\",\n        \"unicode\" : \"1f35d\"\n    },\n    {\n        \"alias\" : \"icecream\",\n        \"unicode\" : \"1f366\"\n    },\n    {\n        \"alias\" : \"shaved_ice\",\n        \"unicode\" : \"1f367\"\n    },\n    {\n        \"alias\" : \"ice_cream\",\n        \"unicode\" : \"1f368\"\n    },\n    {\n        \"alias\" : \"doughnut\",\n        \"unicode\" : \"1f369\"\n    },\n    {\n        \"alias\" : \"cookie\",\n        \"unicode\" : \"1f36a\"\n    },\n    {\n        \"alias\" : \"birthday\",\n        \"unicode\" : \"1f382\"\n    },\n    {\n        \"alias\" : \"cake\",\n        \"unicode\" : \"1f370\"\n    },\n    {\n        \"alias\" : \"chocolate_bar\",\n        \"unicode\" : \"1f36b\"\n    },\n    {\n        \"alias\" : \"candy\",\n        \"unicode\" : \"1f36c\"\n    },\n    {\n        \"alias\" : \"lollipop\",\n        \"unicode\" : \"1f36d\"\n    },\n    {\n        \"alias\" : \"custard\",\n        \"unicode\" : \"1f36e\"\n    },\n    {\n        \"alias\" : \"honey_pot\",\n        \"unicode\" : \"1f36f\"\n    },\n    {\n        \"alias\" : \"coffee\",\n        \"unicode\" : \"2615\"\n    },\n    {\n        \"alias\" : \"sake\",\n        \"unicode\" : \"1f376\"\n    },\n    {\n        \"alias\" : \"tea\",\n        \"unicode\" : \"1f375\"\n    },\n    {\n        \"alias\" : \"wine_glass\",\n        \"unicode\" : \"1f377\"\n    },\n    {\n        \"alias\" : \"cocktail\",\n        \"unicode\" : \"1f378\"\n    },\n    {\n        \"alias\" : \"tropical_drink\",\n        \"unicode\" : \"1f379\"\n    },\n    {\n        \"alias\" : \"beer\",\n        \"unicode\" : \"1f37a\"\n    },\n    {\n        \"alias\" : \"beers\",\n        \"unicode\" : \"1f37b\"\n    },\n    {\n        \"alias\" : \"see_no_evil\",\n        \"unicode\" : \"1f648\"\n    },\n    {\n        \"alias\" : \"hear_no_evil\",\n        \"unicode\" : \"1f649\"\n    },\n    {\n        \"alias\" : \"speak_no_evil\",\n        \"unicode\" : \"1f64a\"\n    },\n    {\n        \"alias\" : \"monkey_face\",\n        \"unicode\" : \"1f435\"\n    },\n    {\n        \"alias\" : \"monkey\",\n        \"unicode\" : \"1f412\"\n    },\n    {\n        \"alias\" : \"wolf\",\n        \"unicode\" : \"1f43a\"\n    },\n    {\n        \"alias\" : \"cat\",\n        \"unicode\" : \"1f408\"\n    },\n    {\n        \"alias\" : \"tiger\",\n        \"unicode\" : \"1f42f\"\n    },\n    {\n        \"alias\" : \"tiger2\",\n        \"unicode\" : \"1f405\"\n    },\n    {\n        \"alias\" : \"leopard\",\n        \"unicode\" : \"1f406\"\n    },\n    {\n        \"alias\" : \"horse\",\n        \"unicode\" : \"1f434\"\n    },\n    {\n        \"alias\" : \"cow\",\n        \"unicode\" : \"1f404\"\n    },\n    {\n        \"alias\" : \"ox\",\n        \"unicode\" : \"1f402\"\n    },\n    {\n        \"alias\" : \"water_buffalo\",\n        \"unicode\" : \"1f403\"\n    },\n    {\n        \"alias\" : \"pig\",\n        \"unicode\" : \"1f416\"\n    },\n    {\n        \"alias\" : \"boar\",\n        \"unicode\" : \"1f417\"\n    },\n    {\n        \"alias\" : \"ram\",\n        \"unicode\" : \"1f410\"\n    },\n    {\n        \"alias\" : \"sheep\",\n        \"unicode\" : \"1f411\"\n    },\n    {\n        \"alias\" : \"elephant\",\n        \"unicode\" : \"1f418\"\n    },\n    {\n        \"alias\" : \"rat\",\n        \"unicode\" : \"1f400\"\n    },\n    {\n        \"alias\" : \"mouse2\",\n        \"unicode\" : \"1f401\"\n    },\n    {\n        \"alias\" : \"hamster\",\n        \"unicode\" : \"1f439\"\n    },\n    {\n        \"alias\" : \"rabbit_face\",\n        \"unicode\" : \"1f430\"\n    },\n    {\n        \"alias\" : \"rabbit\",\n        \"unicode\" : \"1f407\"\n    },\n    {\n        \"alias\" : \"chipmunk\",\n        \"unicode\" : \"1f43f\"\n    },\n    {\n        \"alias\" : \"bear\",\n        \"unicode\" : \"1f43b\"\n    },\n    {\n        \"alias\" : \"koala\",\n        \"unicode\" : \"1f428\"\n    },\n    {\n        \"alias\" : \"panda_face\",\n        \"unicode\" : \"1f43c\"\n    },\n    {\n        \"alias\" : \"rooster\",\n        \"unicode\" : \"1f413\"\n    },\n    {\n        \"alias\" : \"chicken\",\n        \"unicode\" : \"1f414\"\n    },\n    {\n        \"alias\" : \"dog2\",\n        \"unicode\" : \"1f415\"\n    },\n    {\n        \"alias\" : \"poodle\",\n        \"unicode\" : \"1f429\"\n    },\n    {\n        \"alias\" : \"dragon\",\n        \"unicode\" : \"1f409\"\n    },\n    {\n        \"alias\" : \"dragon_face\",\n        \"unicode\" : \"1f432\"\n    },\n    {\n        \"alias\" : \"hatching_chick\",\n        \"unicode\" : \"1f423\"\n    },\n    {\n        \"alias\" : \"baby_chick\",\n        \"unicode\" : \"1f424\"\n    },\n    {\n        \"alias\" : \"hatched_chick\",\n        \"unicode\" : \"1f425\"\n    },\n    {\n        \"alias\" : \"bird\",\n        \"unicode\" : \"1f426\"\n    },\n    {\n        \"alias\" : \"penguin\",\n        \"unicode\" : \"1f427\"\n    },\n    {\n        \"alias\" : \"crocodile\",\n        \"unicode\" : \"1f40a\"\n    },\n    {\n        \"alias\" : \"turtle\",\n        \"unicode\" : \"1f422\"\n    },\n    {\n        \"alias\" : \"whale\",\n        \"unicode\" : \"1f433\"\n    },\n    {\n        \"alias\" : \"tropical_fish\",\n        \"unicode\" : \"1f420\"\n    },\n    {\n        \"alias\" : \"blowfish\",\n        \"unicode\" : \"1f421\"\n    },\n    {\n        \"alias\" : \"octopus\",\n        \"unicode\" : \"1f419\"\n    },\n    {\n        \"alias\" : \"frog\",\n        \"unicode\" : \"1f438\"\n    },\n    {\n        \"alias\" : \"snail\",\n        \"unicode\" : \"1f40c\"\n    },\n    {\n        \"alias\" : \"bouquet\",\n        \"unicode\" : \"1f490\"\n    },\n    {\n        \"alias\" : \"cherry_blossom\",\n        \"unicode\" : \"1f338\"\n    },\n    {\n        \"alias\" : \"rose\",\n        \"unicode\" : \"1f339\"\n    },\n    {\n        \"alias\" : \"hibiscus\",\n        \"unicode\" : \"1f33a\"\n    },\n    {\n        \"alias\" : \"sunflower\",\n        \"unicode\" : \"1f33b\"\n    },\n    {\n        \"alias\" : \"blossom\",\n        \"unicode\" : \"1f33c\"\n    },\n    {\n        \"alias\" : \"tulip\",\n        \"unicode\" : \"1f337\"\n    },\n    {\n        \"alias\" : \"seedling\",\n        \"unicode\" : \"1f331\"\n    },\n    {\n        \"alias\" : \"evergreen_tree\",\n        \"unicode\" : \"1f332\"\n    },\n    {\n        \"alias\" : \"deciduous_tree\",\n        \"unicode\" : \"1f333\"\n    },\n    {\n        \"alias\" : \"palm_tree\",\n        \"unicode\" : \"1f334\"\n    },\n    {\n        \"alias\" : \"cactus\",\n        \"unicode\" : \"1f335\"\n    },\n    {\n        \"alias\" : \"four_leaf_clover\",\n        \"unicode\" : \"1f340\"\n    },\n    {\n        \"alias\" : \"maple_leaf\",\n        \"unicode\" : \"1f341\"\n    },\n    {\n        \"alias\" : \"fallen_leaf\",\n        \"unicode\" : \"1f342\"\n    },\n    {\n        \"alias\" : \"leaves\",\n        \"unicode\" : \"1f343\"\n    },\n    {\n        \"alias\" : \"mushroom\",\n        \"unicode\" : \"1f344\"\n    },\n    {\n        \"alias\" : \"chestnut\",\n        \"unicode\" : \"1f330\"\n    },\n    {\n        \"alias\" : \"crescent_moon\",\n        \"unicode\" : \"1f319\"\n    },\n    {\n        \"alias\" : \"sunny\",\n        \"unicode\" : \"2600\"\n    },\n    {\n        \"alias\" : \"partly_sunny\",\n        \"unicode\" : \"26c5\"\n    },\n    {\n        \"alias\" : \"white_sun_small_cloud\",\n        \"unicode\" : \"1f324\"\n    },\n    {\n        \"alias\" : \"white_sun_cloud\",\n        \"unicode\" : \"1f325\"\n    },\n    {\n        \"alias\" : \"white_sun_rain_cloud\",\n        \"unicode\" : \"1f326\"\n    },\n    {\n        \"alias\" : \"cloud\",\n        \"unicode\" : \"2601\"\n    },\n    {\n        \"alias\" : \"cloud_rain\",\n        \"unicode\" : \"1f327\"\n    },\n    {\n        \"alias\" : \"cloud_snow\",\n        \"unicode\" : \"1f328\"\n    },\n    {\n        \"alias\" : \"cloud_lightning\",\n        \"unicode\" : \"1f329\"\n    },\n    {\n        \"alias\" : \"cloud_tornado\",\n        \"unicode\" : \"1f32a\"\n    },\n    {\n        \"alias\" : \"fog\",\n        \"unicode\" : \"1f32b\"\n    },\n    {\n        \"alias\" : \"wind_blowing_face\",\n        \"unicode\" : \"1f32c\"\n    },\n    {\n        \"alias\" : \"umbrella\",\n        \"unicode\" : \"2614\"\n    },\n    {\n        \"alias\" : \"zap\",\n        \"unicode\" : \"26a1\"\n    },\n    {\n        \"alias\" : \"snowflake\",\n        \"unicode\" : \"2744\"\n    },\n    {\n        \"alias\" : \"sweat_drops\",\n        \"unicode\" : \"1f4a6\"\n    },\n    {\n        \"alias\" : \"droplet\",\n        \"unicode\" : \"1f4a7\"\n    },\n    {\n        \"alias\" : \"ocean\",\n        \"unicode\" : \"1f30a\"\n    },\n    {\n        \"alias\" : \"dash\",\n        \"unicode\" : \"1f4a8\"\n    },\n    {\n        \"alias\" : \"snowman\",\n        \"unicode\" : \"26c4\"\n    },\n    {\n        \"alias\" : \"comet\",\n        \"unicode\" : \"2604\"\n    },\n    {\n        \"alias\" : \"fire\",\n        \"unicode\" : \"1f525\"\n    },\n    {\n        \"alias\" : \"jack_o_lantern\",\n        \"unicode\" : \"1f383\"\n    },\n    {\n        \"alias\" : \"christmas_tree\",\n        \"unicode\" : \"1f384\"\n    },\n    {\n        \"alias\" : \"japan\",\n        \"unicode\" : \"1f5fe\"\n    },\n    {\n        \"alias\" : \"mountain_snow\",\n        \"unicode\" : \"1f3d4\"\n    },\n    {\n        \"alias\" : \"volcano\",\n        \"unicode\" : \"1f30b\"\n    },\n    {\n        \"alias\" : \"mount_fuji\",\n        \"unicode\" : \"1f5fb\"\n    },\n    {\n        \"alias\" : \"camping\",\n        \"unicode\" : \"1f3d5\"\n    },\n    {\n        \"alias\" : \"beach\",\n        \"unicode\" : \"1f3d6\"\n    },\n    {\n        \"alias\" : \"construction_site\",\n        \"unicode\" : \"1f3d7\"\n    },\n    {\n        \"alias\" : \"homes\",\n        \"unicode\" : \"1f3d8\"\n    },\n    {\n        \"alias\" : \"cityscape\",\n        \"unicode\" : \"1f3d9\"\n    },\n    {\n        \"alias\" : \"house_abandoned\",\n        \"unicode\" : \"1f3da\"\n    },\n    {\n        \"alias\" : \"classical_building\",\n        \"unicode\" : \"1f3db\"\n    },\n    {\n        \"alias\" : \"desert\",\n        \"unicode\" : \"1f3dc\"\n    },\n    {\n        \"alias\" : \"island\",\n        \"unicode\" : \"1f3dd\"\n    },\n    {\n        \"alias\" : \"park\",\n        \"unicode\" : \"1f3de\"\n    },\n    {\n        \"alias\" : \"stadium\",\n        \"unicode\" : \"1f3df\"\n    },\n    {\n        \"alias\" : \"house\",\n        \"unicode\" : \"1f3e0\"\n    },\n    {\n        \"alias\" : \"house_with_garden\",\n        \"unicode\" : \"1f3e1\"\n    },\n    {\n        \"alias\" : \"office\",\n        \"unicode\" : \"1f3e2\"\n    },\n    {\n        \"alias\" : \"post_office\",\n        \"unicode\" : \"1f3e3\"\n    },\n    {\n        \"alias\" : \"european_post_office\",\n        \"unicode\" : \"1f3e4\"\n    },\n    {\n        \"alias\" : \"hospital\",\n        \"unicode\" : \"1f3e5\"\n    },\n    {\n        \"alias\" : \"bank\",\n        \"unicode\" : \"1f3e6\"\n    },\n    {\n        \"alias\" : \"hotel\",\n        \"unicode\" : \"1f3e8\"\n    },\n    {\n        \"alias\" : \"love_hotel\",\n        \"unicode\" : \"1f3e9\"\n    },\n    {\n        \"alias\" : \"convenience_store\",\n        \"unicode\" : \"1f3ea\"\n    },\n    {\n        \"alias\" : \"school\",\n        \"unicode\" : \"1f3eb\"\n    },\n    {\n        \"alias\" : \"department_store\",\n        \"unicode\" : \"1f3ec\"\n    },\n    {\n        \"alias\" : \"factory\",\n        \"unicode\" : \"1f3ed\"\n    },\n    {\n        \"alias\" : \"japanese_castle\",\n        \"unicode\" : \"1f3ef\"\n    },\n    {\n        \"alias\" : \"european_castle\",\n        \"unicode\" : \"1f3f0\"\n    },\n    {\n        \"alias\" : \"tokyo_tower\",\n        \"unicode\" : \"1f5fc\"\n    },\n    {\n        \"alias\" : \"statue_of_liberty\",\n        \"unicode\" : \"1f5fd\"\n    },\n    {\n        \"alias\" : \"church\",\n        \"unicode\" : \"26ea\"\n    },\n    {\n        \"alias\" : \"fountain\",\n        \"unicode\" : \"26f2\"\n    },\n    {\n        \"alias\" : \"tent\",\n        \"unicode\" : \"26fa\"\n    },\n    {\n        \"alias\" : \"foggy\",\n        \"unicode\" : \"1f301\"\n    },\n    {\n        \"alias\" : \"night_with_stars\",\n        \"unicode\" : \"1f303\"\n    },\n    {\n        \"alias\" : \"sunrise_over_mountains\",\n        \"unicode\" : \"1f304\"\n    },\n    {\n        \"alias\" : \"sunrise\",\n        \"unicode\" : \"1f305\"\n    },\n    {\n        \"alias\" : \"city_dusk\",\n        \"unicode\" : \"1f306\"\n    },\n    {\n        \"alias\" : \"city_sunset\",\n        \"unicode\" : \"1f307\"\n    },\n    {\n        \"alias\" : \"bridge_at_night\",\n        \"unicode\" : \"1f309\"\n    },\n    {\n        \"alias\" : \"milky_way\",\n        \"unicode\" : \"1f30c\"\n    },\n    {\n        \"alias\" : \"helicopter\",\n        \"unicode\" : \"1f681\"\n    },\n    {\n        \"alias\" : \"steam_locomotive\",\n        \"unicode\" : \"1f682\"\n    },\n    {\n        \"alias\" : \"railway_car\",\n        \"unicode\" : \"1f683\"\n    },\n    {\n        \"alias\" : \"bullettrain_side\",\n        \"unicode\" : \"1f684\"\n    },\n    {\n        \"alias\" : \"bullettrain_front\",\n        \"unicode\" : \"1f685\"\n    },\n    {\n        \"alias\" : \"train2\",\n        \"unicode\" : \"1f686\"\n    },\n    {\n        \"alias\" : \"metro\",\n        \"unicode\" : \"1f687\"\n    },\n    {\n        \"alias\" : \"light_rail\",\n        \"unicode\" : \"1f688\"\n    },\n    {\n        \"alias\" : \"station\",\n        \"unicode\" : \"1f689\"\n    },\n    {\n        \"alias\" : \"tram\",\n        \"unicode\" : \"1f68a\"\n    },\n    {\n        \"alias\" : \"train\",\n        \"unicode\" : \"1f68b\"\n    },\n    {\n        \"alias\" : \"bus\",\n        \"unicode\" : \"1f68c\"\n    },\n    {\n        \"alias\" : \"oncoming_bus\",\n        \"unicode\" : \"1f68d\"\n    },\n    {\n        \"alias\" : \"trolleybus\",\n        \"unicode\" : \"1f68e\"\n    },\n    {\n        \"alias\" : \"busstop\",\n        \"unicode\" : \"1f68f\"\n    },\n    {\n        \"alias\" : \"minibus\",\n        \"unicode\" : \"1f690\"\n    },\n    {\n        \"alias\" : \"ambulance\",\n        \"unicode\" : \"1f691\"\n    },\n    {\n        \"alias\" : \"fire_engine\",\n        \"unicode\" : \"1f692\"\n    },\n    {\n        \"alias\" : \"police_car\",\n        \"unicode\" : \"1f693\"\n    },\n    {\n        \"alias\" : \"oncoming_police_car\",\n        \"unicode\" : \"1f694\"\n    },\n    {\n        \"alias\" : \"taxi\",\n        \"unicode\" : \"1f695\"\n    },\n    {\n        \"alias\" : \"oncoming_taxi\",\n        \"unicode\" : \"1f696\"\n    },\n    {\n        \"alias\" : \"red_car\",\n        \"unicode\" : \"1f697\"\n    },\n    {\n        \"alias\" : \"oncoming_automobile\",\n        \"unicode\" : \"1f698\"\n    },\n    {\n        \"alias\" : \"blue_car\",\n        \"unicode\" : \"1f699\"\n    },\n    {\n        \"alias\" : \"truck\",\n        \"unicode\" : \"1f69a\"\n    },\n    {\n        \"alias\" : \"articulated_lorry\",\n        \"unicode\" : \"1f69b\"\n    },\n    {\n        \"alias\" : \"tractor\",\n        \"unicode\" : \"1f69c\"\n    },\n    {\n        \"alias\" : \"motorway\",\n        \"unicode\" : \"1f6e3\"\n    },\n    {\n        \"alias\" : \"railway_track\",\n        \"unicode\" : \"1f6e4\"\n    },\n    {\n        \"alias\" : \"fuelpump\",\n        \"unicode\" : \"26fd\"\n    },\n    {\n        \"alias\" : \"monorail\",\n        \"unicode\" : \"1f69d\"\n    },\n    {\n        \"alias\" : \"mountain_railway\",\n        \"unicode\" : \"1f69e\"\n    },\n    {\n        \"alias\" : \"suspension_railway\",\n        \"unicode\" : \"1f69f\"\n    },\n    {\n        \"alias\" : \"mountain_cableway\",\n        \"unicode\" : \"1f6a0\"\n    },\n    {\n        \"alias\" : \"aerial_tramway\",\n        \"unicode\" : \"1f6a1\"\n    },\n    {\n        \"alias\" : \"traffic_light\",\n        \"unicode\" : \"1f6a5\"\n    },\n    {\n        \"alias\" : \"vertical_traffic_light\",\n        \"unicode\" : \"1f6a6\"\n    },\n    {\n        \"alias\" : \"construction\",\n        \"unicode\" : \"1f6a7\"\n    },\n    {\n        \"alias\" : \"rotating_light\",\n        \"unicode\" : \"1f6a8\"\n    },\n    {\n        \"alias\" : \"sailboat\",\n        \"unicode\" : \"26f5\"\n    },\n    {\n        \"alias\" : \"cruise_ship\",\n        \"unicode\" : \"1f6f3\"\n    },\n    {\n        \"alias\" : \"ferry\",\n        \"unicode\" : \"26f4\"\n    },\n    {\n        \"alias\" : \"motorboat\",\n        \"unicode\" : \"1f6e5\"\n    },\n    {\n        \"alias\" : \"ship\",\n        \"unicode\" : \"1f6a2\"\n    },\n    {\n        \"alias\" : \"airplane\",\n        \"unicode\" : \"2708\"\n    },\n    {\n        \"alias\" : \"airplane_small\",\n        \"unicode\" : \"1f6e9\"\n    },\n    {\n        \"alias\" : \"airplane_departure\",\n        \"unicode\" : \"1f6eb\"\n    },\n    {\n        \"alias\" : \"airplane_arriving\",\n        \"unicode\" : \"1f6ec\"\n    },\n    {\n        \"alias\" : \"rocket\",\n        \"unicode\" : \"1f680\"\n    },\n    {\n        \"alias\" : \"satellite_orbital\",\n        \"unicode\" : \"1f6f0\"\n    },\n    {\n        \"alias\" : \"stars\",\n        \"unicode\" : \"1f320\"\n    },\n    {\n        \"alias\" : \"rainbow\",\n        \"unicode\" : \"1f308\"\n    },\n    {\n        \"alias\" : \"rice_scene\",\n        \"unicode\" : \"1f391\"\n    },\n    {\n        \"alias\" : \"love_letter\",\n        \"unicode\" : \"1f48c\"\n    },\n    {\n        \"alias\" : \"bomb\",\n        \"unicode\" : \"1f4a3\"\n    },\n    {\n        \"alias\" : \"hole\",\n        \"unicode\" : \"1f573\"\n    },\n    {\n        \"alias\" : \"shopping_bags\",\n        \"unicode\" : \"1f6cd\"\n    },\n    {\n        \"alias\" : \"barber\",\n        \"unicode\" : \"1f488\"\n    },\n    {\n        \"alias\" : \"syringe\",\n        \"unicode\" : \"1f489\"\n    },\n    {\n        \"alias\" : \"pill\",\n        \"unicode\" : \"1f48a\"\n    },\n    {\n        \"alias\" : \"hourglass\",\n        \"unicode\" : \"231b\"\n    },\n    {\n        \"alias\" : \"hourglass_flowing_sand\",\n        \"unicode\" : \"23f3\"\n    },\n    {\n        \"alias\" : \"watch\",\n        \"unicode\" : \"231a\"\n    },\n    {\n        \"alias\" : \"alarm_clock\",\n        \"unicode\" : \"23f0\"\n    },\n    {\n        \"alias\" : \"thermometer\",\n        \"unicode\" : \"1f321\"\n    },\n    {\n        \"alias\" : \"iphone\",\n        \"unicode\" : \"1f4f1\"\n    },\n    {\n        \"alias\" : \"calling\",\n        \"unicode\" : \"1f4f2\"\n    },\n    {\n        \"alias\" : \"ribbon\",\n        \"unicode\" : \"1f380\"\n    },\n    {\n        \"alias\" : \"gift\",\n        \"unicode\" : \"1f381\"\n    },\n    {\n        \"alias\" : \"bulb\",\n        \"unicode\" : \"1f4a1\"\n    },\n    {\n        \"alias\" : \"telephone\",\n        \"unicode\" : \"260e\"\n    },\n    {\n        \"alias\" : \"bed\",\n        \"unicode\" : \"1f6cf-fe0f\"\n    },\n    {\n        \"alias\" : \"couch\",\n        \"unicode\" : \"1f6cb\"\n    },\n    {\n        \"alias\" : \"shower\",\n        \"unicode\" : \"1f6bf\"\n    },\n    {\n        \"alias\" : \"bathtub\",\n        \"unicode\" : \"1f6c1\"\n    },\n    {\n        \"alias\" : \"bellhop\",\n        \"unicode\" : \"1f6ce\"\n    },\n    {\n        \"alias\" : \"joystick\",\n        \"unicode\" : \"1f579-fe0f\"\n    },\n    {\n        \"alias\" : \"tv\",\n        \"unicode\" : \"1f4fa\"\n    },\n    {\n        \"alias\" : \"camera\",\n        \"unicode\" : \"1f4f7\"\n    },\n    {\n        \"alias\" : \"camera_with_flash\",\n        \"unicode\" : \"1f4f8\"\n    },\n    {\n        \"alias\" : \"video_camera\",\n        \"unicode\" : \"1f4f9\"\n    },\n    {\n        \"alias\" : \"projector\",\n        \"unicode\" : \"1f4fd\"\n    },\n    {\n        \"alias\" : \"fax\",\n        \"unicode\" : \"1f4e0\"\n    },\n    {\n        \"alias\" : \"satellite\",\n        \"unicode\" : \"1f4e1\"\n    },\n    {\n        \"alias\" : \"outbox_tray\",\n        \"unicode\" : \"1f4e4\"\n    },\n    {\n        \"alias\" : \"inbox_tray\",\n        \"unicode\" : \"1f4e5\"\n    },\n    {\n        \"alias\" : \"package\",\n        \"unicode\" : \"1f4e6\"\n    },\n    {\n        \"alias\" : \"e_mail\",\n        \"unicode\" : \"1f4e7\"\n    },\n    {\n        \"alias\" : \"incoming_envelope\",\n        \"unicode\" : \"1f4e8\"\n    },\n    {\n        \"alias\" : \"envelope_with_arrow\",\n        \"unicode\" : \"1f4e9\"\n    },\n    {\n        \"alias\" : \"mailbox_closed\",\n        \"unicode\" : \"1f4ea\"\n    },\n    {\n        \"alias\" : \"mailbox\",\n        \"unicode\" : \"1f4eb\"\n    },\n    {\n        \"alias\" : \"mailbox_with_mail\",\n        \"unicode\" : \"1f4ec\"\n    },\n    {\n        \"alias\" : \"mailbox_with_no_mail\",\n        \"unicode\" : \"1f4ed\"\n    },\n    {\n        \"alias\" : \"postal_horn\",\n        \"unicode\" : \"1f4ef\"\n    },\n    {\n        \"alias\" : \"newspaper\",\n        \"unicode\" : \"1f4f0\"\n    },\n    {\n        \"alias\" : \"dagger\",\n        \"unicode\" : \"1f5e1\"\n    },\n    {\n        \"alias\" : \"shield\",\n        \"unicode\" : \"1f6e1\"\n    },\n    {\n        \"alias\" : \"moyai\",\n        \"unicode\" : \"1f5ff\"\n    },\n    {\n        \"alias\" : \"crystal_ball\",\n        \"unicode\" : \"1f52e\"\n    },\n    {\n        \"alias\" : \"100\",\n        \"unicode\" : \"1f4af\"\n    },\n    {\n        \"alias\" : \"cupid\",\n        \"unicode\" : \"1f498\"\n    },\n    {\n        \"alias\" : \"heart\",\n        \"unicode\" : \"2764-fe0f\"\n    },\n    {\n        \"alias\" : \"broken_heart\",\n        \"unicode\" : \"1f494\"\n    },\n    {\n        \"alias\" : \"two_hearts\",\n        \"unicode\" : \"1f495\"\n    },\n    {\n        \"alias\" : \"heartbeat\",\n        \"unicode\" : \"1f493\"\n    },\n    {\n        \"alias\" : \"heartpulse\",\n        \"unicode\" : \"1f497\"\n    },\n    {\n        \"alias\" : \"blue_heart\",\n        \"unicode\" : \"1f499\"\n    },\n    {\n        \"alias\" : \"green_heart\",\n        \"unicode\" : \"1f49a\"\n    },\n    {\n        \"alias\" : \"yellow_heart\",\n        \"unicode\" : \"1f49b\"\n    },\n    {\n        \"alias\" : \"purple_heart\",\n        \"unicode\" : \"1f49c\"\n    },\n    {\n        \"alias\" : \"sparkling_heart\",\n        \"unicode\" : \"1f496\"\n    },\n    {\n        \"alias\" : \"gift_heart\",\n        \"unicode\" : \"1f49d\"\n    },\n    {\n        \"alias\" : \"revolving_hearts\",\n        \"unicode\" : \"1f49e\"\n    },\n    {\n        \"alias\" : \"heart_decoration\",\n        \"unicode\" : \"1f49f\"\n    },\n    {\n        \"alias\" : \"heart_exclamation\",\n        \"unicode\" : \"2763\"\n    },\n    {\n        \"alias\" : \"anger\",\n        \"unicode\" : \"1f4a2\"\n    },\n    {\n        \"alias\" : \"dizzy\",\n        \"unicode\" : \"1f4ab\"\n    },\n    {\n        \"alias\" : \"boom\",\n        \"unicode\" : \"1f4a5\"\n    },\n    {\n        \"alias\" : \"loudspeaker\",\n        \"unicode\" : \"1f4e2\"\n    },\n    {\n        \"alias\" : \"mega\",\n        \"unicode\" : \"1f4e3\"\n    },\n    {\n        \"alias\" : \"atm\",\n        \"unicode\" : \"1f3e7\"\n    },\n    {\n        \"alias\" : \"copyright\",\n        \"unicode\" : \"00a9\"\n    },\n    {\n        \"alias\" : \"registered\",\n        \"unicode\" : \"00ae\"\n    },\n    {\n        \"alias\" : \"au\",\n        \"unicode\" : \"1f1e6-1f1fa\"\n    },\n    {\n        \"alias\" : \"at\",\n        \"unicode\" : \"1f1e6-1f1f9\"\n    },\n    {\n        \"alias\" : \"be\",\n        \"unicode\" : \"1f1e7-1f1ea\"\n    },\n    {\n        \"alias\" : \"br\",\n        \"unicode\" : \"1f1e7-1f1f7\"\n    },\n    {\n        \"alias\" : \"ca\",\n        \"unicode\" : \"1f1e8-1f1e6\"\n    },\n    {\n        \"alias\" : \"chile\",\n        \"unicode\" : \"1f1e8-1f1f1\"\n    },\n    {\n        \"alias\" : \"cn\",\n        \"unicode\" : \"1f1e8-1f1f3\"\n    },\n    {\n        \"alias\" : \"dk\",\n        \"unicode\" : \"1f1e9-1f1f0\"\n    },\n    {\n        \"alias\" : \"fi\",\n        \"unicode\" : \"1f1eb-1f1ee\"\n    },\n    {\n        \"alias\" : \"fr\",\n        \"unicode\" : \"1f1eb-1f1f7\"\n    },\n    {\n        \"alias\" : \"de\",\n        \"unicode\" : \"1f1e9-1f1ea\"\n    },\n    {\n        \"alias\" : \"hk\",\n        \"unicode\" : \"1f1ed-1f1f0\"\n    },\n    {\n        \"alias\" : \"in\",\n        \"unicode\" : \"1f1ee-1f1f3\"\n    },\n    {\n        \"alias\" : \"indonesia\",\n        \"unicode\" : \"1f1ee-1f1e9\"\n    },\n    {\n        \"alias\" : \"ie\",\n        \"unicode\" : \"1f1ee-1f1ea\"\n    },\n    {\n        \"alias\" : \"il\",\n        \"unicode\" : \"1f1ee-1f1f1\"\n    },\n    {\n        \"alias\" : \"it\",\n        \"unicode\" : \"1f1ee-1f1f9\"\n    },\n    {\n        \"alias\" : \"jp\",\n        \"unicode\" : \"1f1ef-1f1f5\"\n    },\n    {\n        \"alias\" : \"kr\",\n        \"unicode\" : \"1f1f0-1f1f7\"\n    },\n    {\n        \"alias\" : \"mo\",\n        \"unicode\" : \"1f1f2-1f1f4\"\n    },\n    {\n        \"alias\" : \"my\",\n        \"unicode\" : \"1f1f2-1f1fe\"\n    },\n    {\n        \"alias\" : \"mx\",\n        \"unicode\" : \"1f1f2-1f1fd\"\n    },\n    {\n        \"alias\" : \"nl\",\n        \"unicode\" : \"1f1f3-1f1f1\"\n    },\n    {\n        \"alias\" : \"nz\",\n        \"unicode\" : \"1f1f3-1f1ff\"\n    },\n    {\n        \"alias\" : \"no\",\n        \"unicode\" : \"1f1f3-1f1f4\"\n    },\n    {\n        \"alias\" : \"ph\",\n        \"unicode\" : \"1f1f5-1f1ed\"\n    },\n    {\n        \"alias\" : \"pl\",\n        \"unicode\" : \"1f1f5-1f1f1\"\n    },\n    {\n        \"alias\" : \"pt\",\n        \"unicode\" : \"1f1f5-1f1f9\"\n    },\n    {\n        \"alias\" : \"pr\",\n        \"unicode\" : \"1f1f5-1f1f7\"\n    },\n    {\n        \"alias\" : \"ru\",\n        \"unicode\" : \"1f1f7-1f1fa\"\n    },\n    {\n        \"alias\" : \"saudi\",\n        \"unicode\" : \"1f1f8-1f1e6\"\n    },\n    {\n        \"alias\" : \"sg\",\n        \"unicode\" : \"1f1f8-1f1ec\"\n    },\n    {\n        \"alias\" : \"za\",\n        \"unicode\" : \"1f1ff-1f1e6\"\n    },\n    {\n        \"alias\" : \"es\",\n        \"unicode\" : \"1f1ea-1f1f8\"\n    },\n    {\n        \"alias\" : \"se\",\n        \"unicode\" : \"1f1f8-1f1ea\"\n    },\n    {\n        \"alias\" : \"ch\",\n        \"unicode\" : \"1f1e8-1f1ed\"\n    },\n    {\n        \"alias\" : \"tr\",\n        \"unicode\" : \"1f1f9-1f1f7\"\n    },\n    {\n        \"alias\" : \"gb\",\n        \"unicode\" : \"1f1ec-1f1e7\"\n    },\n    {\n        \"alias\" : \"us\",\n        \"unicode\" : \"1f1fa-1f1f8\"\n    },\n    {\n        \"alias\" : \"ae\",\n        \"unicode\" : \"1f1e6-1f1ea\"\n    },\n    {\n        \"alias\" : \"vn\",\n        \"unicode\" : \"1f1fb-1f1f3\"\n    },\n    {\n        \"alias\" : \"af\",\n        \"unicode\" : \"1f1e6-1f1eb\"\n    },\n    {\n        \"alias\" : \"al\",\n        \"unicode\" : \"1f1e6-1f1f1\"\n    },\n    {\n        \"alias\" : \"dz\",\n        \"unicode\" : \"1f1e9-1f1ff\"\n    },\n    {\n        \"alias\" : \"ad\",\n        \"unicode\" : \"1f1e6-1f1e9\"\n    },\n    {\n        \"alias\" : \"ao\",\n        \"unicode\" : \"1f1e6-1f1f4\"\n    },\n    {\n        \"alias\" : \"ai\",\n        \"unicode\" : \"1f1e6-1f1ee\"\n    },\n    {\n        \"alias\" : \"ag\",\n        \"unicode\" : \"1f1e6-1f1ec\"\n    },\n    {\n        \"alias\" : \"ar\",\n        \"unicode\" : \"1f1e6-1f1f7\"\n    },\n    {\n        \"alias\" : \"am\",\n        \"unicode\" : \"1f1e6-1f1f2\"\n    },\n    {\n        \"alias\" : \"aw\",\n        \"unicode\" : \"1f1e6-1f1fc\"\n    },\n    {\n        \"alias\" : \"ac\",\n        \"unicode\" : \"1f1e6-1f1e8\"\n    },\n    {\n        \"alias\" : \"az\",\n        \"unicode\" : \"1f1e6-1f1ff\"\n    },\n    {\n        \"alias\" : \"bs\",\n        \"unicode\" : \"1f1e7-1f1f8\"\n    },\n    {\n        \"alias\" : \"bh\",\n        \"unicode\" : \"1f1e7-1f1ed\"\n    },\n    {\n        \"alias\" : \"bd\",\n        \"unicode\" : \"1f1e7-1f1e9\"\n    },\n    {\n        \"alias\" : \"bb\",\n        \"unicode\" : \"1f1e7-1f1e7\"\n    },\n    {\n        \"alias\" : \"by\",\n        \"unicode\" : \"1f1e7-1f1fe\"\n    },\n    {\n        \"alias\" : \"bz\",\n        \"unicode\" : \"1f1e7-1f1ff\"\n    },\n    {\n        \"alias\" : \"bj\",\n        \"unicode\" : \"1f1e7-1f1ef\"\n    },\n    {\n        \"alias\" : \"bm\",\n        \"unicode\" : \"1f1e7-1f1f2\"\n    },\n    {\n        \"alias\" : \"bt\",\n        \"unicode\" : \"1f1e7-1f1f9\"\n    },\n    {\n        \"alias\" : \"bo\",\n        \"unicode\" : \"1f1e7-1f1f4\"\n    },\n    {\n        \"alias\" : \"ba\",\n        \"unicode\" : \"1f1e7-1f1e6\"\n    },\n    {\n        \"alias\" : \"bw\",\n        \"unicode\" : \"1f1e7-1f1fc\"\n    },\n    {\n        \"alias\" : \"bn\",\n        \"unicode\" : \"1f1e7-1f1f3\"\n    },\n    {\n        \"alias\" : \"bg\",\n        \"unicode\" : \"1f1e7-1f1ec\"\n    },\n    {\n        \"alias\" : \"bf\",\n        \"unicode\" : \"1f1e7-1f1eb\"\n    },\n    {\n        \"alias\" : \"bi\",\n        \"unicode\" : \"1f1e7-1f1ee\"\n    },\n    {\n        \"alias\" : \"kh\",\n        \"unicode\" : \"1f1f0-1f1ed\"\n    },\n    {\n        \"alias\" : \"cm\",\n        \"unicode\" : \"1f1e8-1f1f2\"\n    },\n    {\n        \"alias\" : \"cv\",\n        \"unicode\" : \"1f1e8-1f1fb\"\n    },\n    {\n        \"alias\" : \"ky\",\n        \"unicode\" : \"1f1f0-1f1fe\"\n    },\n    {\n        \"alias\" : \"cf\",\n        \"unicode\" : \"1f1e8-1f1eb\"\n    },\n    {\n        \"alias\" : \"km\",\n        \"unicode\" : \"1f1f0-1f1f2\"\n    },\n    {\n        \"alias\" : \"congo\",\n        \"unicode\" : \"1f1e8-1f1e9\"\n    },\n    {\n        \"alias\" : \"cg\",\n        \"unicode\" : \"1f1e8-1f1ec\"\n    },\n    {\n        \"alias\" : \"td\",\n        \"unicode\" : \"1f1f9-1f1e9\"\n    },\n    {\n        \"alias\" : \"cr\",\n        \"unicode\" : \"1f1e8-1f1f7\"\n    },\n    {\n        \"alias\" : \"ci\",\n        \"unicode\" : \"1f1e8-1f1ee\"\n    },\n    {\n        \"alias\" : \"hr\",\n        \"unicode\" : \"1f1ed-1f1f7\"\n    },\n    {\n        \"alias\" : \"cu\",\n        \"unicode\" : \"1f1e8-1f1fa\"\n    },\n    {\n        \"alias\" : \"cy\",\n        \"unicode\" : \"1f1e8-1f1fe\"\n    },\n    {\n        \"alias\" : \"cz\",\n        \"unicode\" : \"1f1e8-1f1ff\"\n    },\n    {\n        \"alias\" : \"dj\",\n        \"unicode\" : \"1f1e9-1f1ef\"\n    },\n    {\n        \"alias\" : \"dm\",\n        \"unicode\" : \"1f1e9-1f1f2\"\n    },\n    {\n        \"alias\" : \"do\",\n        \"unicode\" : \"1f1e9-1f1f4\"\n    },\n    {\n        \"alias\" : \"tl\",\n        \"unicode\" : \"1f1f9-1f1f1\"\n    },\n    {\n        \"alias\" : \"ec\",\n        \"unicode\" : \"1f1ea-1f1e8\"\n    },\n    {\n        \"alias\" : \"eg\",\n        \"unicode\" : \"1f1ea-1f1ec\"\n    },\n    {\n        \"alias\" : \"sv\",\n        \"unicode\" : \"1f1f8-1f1fb\"\n    },\n    {\n        \"alias\" : \"gq\",\n        \"unicode\" : \"1f1ec-1f1f6\"\n    },\n    {\n        \"alias\" : \"er\",\n        \"unicode\" : \"1f1ea-1f1f7\"\n    },\n    {\n        \"alias\" : \"ee\",\n        \"unicode\" : \"1f1ea-1f1ea\"\n    },\n    {\n        \"alias\" : \"et\",\n        \"unicode\" : \"1f1ea-1f1f9\"\n    },\n    {\n        \"alias\" : \"fk\",\n        \"unicode\" : \"1f1eb-1f1f0\"\n    },\n    {\n        \"alias\" : \"fo\",\n        \"unicode\" : \"1f1eb-1f1f4\"\n    },\n    {\n        \"alias\" : \"fj\",\n        \"unicode\" : \"1f1eb-1f1ef\"\n    },\n    {\n        \"alias\" : \"pf\",\n        \"unicode\" : \"1f1f5-1f1eb\"\n    },\n    {\n        \"alias\" : \"ga\",\n        \"unicode\" : \"1f1ec-1f1e6\"\n    },\n    {\n        \"alias\" : \"gm\",\n        \"unicode\" : \"1f1ec-1f1f2\"\n    },\n    {\n        \"alias\" : \"ge\",\n        \"unicode\" : \"1f1ec-1f1ea\"\n    },\n    {\n        \"alias\" : \"gh\",\n        \"unicode\" : \"1f1ec-1f1ed\"\n    },\n    {\n        \"alias\" : \"gi\",\n        \"unicode\" : \"1f1ec-1f1ee\"\n    },\n    {\n        \"alias\" : \"gr\",\n        \"unicode\" : \"1f1ec-1f1f7\"\n    },\n    {\n        \"alias\" : \"gl\",\n        \"unicode\" : \"1f1ec-1f1f1\"\n    },\n    {\n        \"alias\" : \"gd\",\n        \"unicode\" : \"1f1ec-1f1e9\"\n    },\n    {\n        \"alias\" : \"gu\",\n        \"unicode\" : \"1f1ec-1f1fa\"\n    },\n    {\n        \"alias\" : \"gt\",\n        \"unicode\" : \"1f1ec-1f1f9\"\n    },\n    {\n        \"alias\" : \"gn\",\n        \"unicode\" : \"1f1ec-1f1f3\"\n    },\n    {\n        \"alias\" : \"gw\",\n        \"unicode\" : \"1f1ec-1f1fc\"\n    },\n    {\n        \"alias\" : \"gy\",\n        \"unicode\" : \"1f1ec-1f1fe\"\n    },\n    {\n        \"alias\" : \"ht\",\n        \"unicode\" : \"1f1ed-1f1f9\"\n    },\n    {\n        \"alias\" : \"hn\",\n        \"unicode\" : \"1f1ed-1f1f3\"\n    },\n    {\n        \"alias\" : \"hu\",\n        \"unicode\" : \"1f1ed-1f1fa\"\n    },\n    {\n        \"alias\" : \"is\",\n        \"unicode\" : \"1f1ee-1f1f8\"\n    },\n    {\n        \"alias\" : \"ir\",\n        \"unicode\" : \"1f1ee-1f1f7\"\n    },\n    {\n        \"alias\" : \"iq\",\n        \"unicode\" : \"1f1ee-1f1f6\"\n    },\n    {\n        \"alias\" : \"jm\",\n        \"unicode\" : \"1f1ef-1f1f2\"\n    },\n    {\n        \"alias\" : \"je\",\n        \"unicode\" : \"1f1ef-1f1ea\"\n    },\n    {\n        \"alias\" : \"jo\",\n        \"unicode\" : \"1f1ef-1f1f4\"\n    },\n    {\n        \"alias\" : \"kz\",\n        \"unicode\" : \"1f1f0-1f1ff\"\n    },\n    {\n        \"alias\" : \"ke\",\n        \"unicode\" : \"1f1f0-1f1ea\"\n    },\n    {\n        \"alias\" : \"ki\",\n        \"unicode\" : \"1f1f0-1f1ee\"\n    },\n    {\n        \"alias\" : \"xk\",\n        \"unicode\" : \"1f1fd-1f1f0\"\n    },\n    {\n        \"alias\" : \"kw\",\n        \"unicode\" : \"1f1f0-1f1fc\"\n    },\n    {\n        \"alias\" : \"kg\",\n        \"unicode\" : \"1f1f0-1f1ec\"\n    },\n    {\n        \"alias\" : \"la\",\n        \"unicode\" : \"1f1f1-1f1e6\"\n    },\n    {\n        \"alias\" : \"lv\",\n        \"unicode\" : \"1f1f1-1f1fb\"\n    },\n    {\n        \"alias\" : \"lb\",\n        \"unicode\" : \"1f1f1-1f1e7\"\n    },\n    {\n        \"alias\" : \"ls\",\n        \"unicode\" : \"1f1f1-1f1f8\"\n    },\n    {\n        \"alias\" : \"lr\",\n        \"unicode\" : \"1f1f1-1f1f7\"\n    },\n    {\n        \"alias\" : \"ly\",\n        \"unicode\" : \"1f1f1-1f1fe\"\n    },\n    {\n        \"alias\" : \"li\",\n        \"unicode\" : \"1f1f1-1f1ee\"\n    },\n    {\n        \"alias\" : \"lt\",\n        \"unicode\" : \"1f1f1-1f1f9\"\n    },\n    {\n        \"alias\" : \"lu\",\n        \"unicode\" : \"1f1f1-1f1fa\"\n    },\n    {\n        \"alias\" : \"mk\",\n        \"unicode\" : \"1f1f2-1f1f0\"\n    },\n    {\n        \"alias\" : \"mg\",\n        \"unicode\" : \"1f1f2-1f1ec\"\n    },\n    {\n        \"alias\" : \"mw\",\n        \"unicode\" : \"1f1f2-1f1fc\"\n    },\n    {\n        \"alias\" : \"mv\",\n        \"unicode\" : \"1f1f2-1f1fb\"\n    },\n    {\n        \"alias\" : \"ml\",\n        \"unicode\" : \"1f1f2-1f1f1\"\n    },\n    {\n        \"alias\" : \"mt\",\n        \"unicode\" : \"1f1f2-1f1f9\"\n    },\n    {\n        \"alias\" : \"mh\",\n        \"unicode\" : \"1f1f2-1f1ed\"\n    },\n    {\n        \"alias\" : \"mr\",\n        \"unicode\" : \"1f1f2-1f1f7\"\n    },\n    {\n        \"alias\" : \"mu\",\n        \"unicode\" : \"1f1f2-1f1fa\"\n    },\n    {\n        \"alias\" : \"fm\",\n        \"unicode\" : \"1f1eb-1f1f2\"\n    },\n    {\n        \"alias\" : \"md\",\n        \"unicode\" : \"1f1f2-1f1e9\"\n    },\n    {\n        \"alias\" : \"mc\",\n        \"unicode\" : \"1f1f2-1f1e8\"\n    },\n    {\n        \"alias\" : \"mn\",\n        \"unicode\" : \"1f1f2-1f1f3\"\n    },\n    {\n        \"alias\" : \"me\",\n        \"unicode\" : \"1f1f2-1f1ea\"\n    },\n    {\n        \"alias\" : \"ms\",\n        \"unicode\" : \"1f1f2-1f1f8\"\n    },\n    {\n        \"alias\" : \"ma\",\n        \"unicode\" : \"1f1f2-1f1e6\"\n    },\n    {\n        \"alias\" : \"mz\",\n        \"unicode\" : \"1f1f2-1f1ff\"\n    },\n    {\n        \"alias\" : \"mm\",\n        \"unicode\" : \"1f1f2-1f1f2\"\n    },\n    {\n        \"alias\" : \"na\",\n        \"unicode\" : \"1f1f3-1f1e6\"\n    },\n    {\n        \"alias\" : \"nr\",\n        \"unicode\" : \"1f1f3-1f1f7\"\n    },\n    {\n        \"alias\" : \"np\",\n        \"unicode\" : \"1f1f3-1f1f5\"\n    },\n    {\n        \"alias\" : \"nc\",\n        \"unicode\" : \"1f1f3-1f1e8\"\n    },\n    {\n        \"alias\" : \"ni\",\n        \"unicode\" : \"1f1f3-1f1ee\"\n    },\n    {\n        \"alias\" : \"ne\",\n        \"unicode\" : \"1f1f3-1f1ea\"\n    },\n    {\n        \"alias\" : \"nigeria\",\n        \"unicode\" : \"1f1f3-1f1ec\"\n    },\n    {\n        \"alias\" : \"nu\",\n        \"unicode\" : \"1f1f3-1f1fa\"\n    },\n    {\n        \"alias\" : \"kp\",\n        \"unicode\" : \"1f1f0-1f1f5\"\n    },\n    {\n        \"alias\" : \"om\",\n        \"unicode\" : \"1f1f4-1f1f2\"\n    },\n    {\n        \"alias\" : \"pk\",\n        \"unicode\" : \"1f1f5-1f1f0\"\n    },\n    {\n        \"alias\" : \"pw\",\n        \"unicode\" : \"1f1f5-1f1fc\"\n    },\n    {\n        \"alias\" : \"ps\",\n        \"unicode\" : \"1f1f5-1f1f8\"\n    },\n    {\n        \"alias\" : \"pa\",\n        \"unicode\" : \"1f1f5-1f1e6\"\n    },\n    {\n        \"alias\" : \"pg\",\n        \"unicode\" : \"1f1f5-1f1ec\"\n    },\n    {\n        \"alias\" : \"py\",\n        \"unicode\" : \"1f1f5-1f1fe\"\n    },\n    {\n        \"alias\" : \"pe\",\n        \"unicode\" : \"1f1f5-1f1ea\"\n    },\n    {\n        \"alias\" : \"qa\",\n        \"unicode\" : \"1f1f6-1f1e6\"\n    },\n    {\n        \"alias\" : \"ro\",\n        \"unicode\" : \"1f1f7-1f1f4\"\n    },\n    {\n        \"alias\" : \"rw\",\n        \"unicode\" : \"1f1f7-1f1fc\"\n    },\n    {\n        \"alias\" : \"sh\",\n        \"unicode\" : \"1f1f8-1f1ed\"\n    },\n    {\n        \"alias\" : \"kn\",\n        \"unicode\" : \"1f1f0-1f1f3\"\n    },\n    {\n        \"alias\" : \"lc\",\n        \"unicode\" : \"1f1f1-1f1e8\"\n    },\n    {\n        \"alias\" : \"vc\",\n        \"unicode\" : \"1f1fb-1f1e8\"\n    },\n    {\n        \"alias\" : \"ws\",\n        \"unicode\" : \"1f1fc-1f1f8\"\n    },\n    {\n        \"alias\" : \"sm\",\n        \"unicode\" : \"1f1f8-1f1f2\"\n    },\n    {\n        \"alias\" : \"st\",\n        \"unicode\" : \"1f1f8-1f1f9\"\n    },\n    {\n        \"alias\" : \"sn\",\n        \"unicode\" : \"1f1f8-1f1f3\"\n    },\n    {\n        \"alias\" : \"rs\",\n        \"unicode\" : \"1f1f7-1f1f8\"\n    },\n    {\n        \"alias\" : \"sc\",\n        \"unicode\" : \"1f1f8-1f1e8\"\n    },\n    {\n        \"alias\" : \"sl\",\n        \"unicode\" : \"1f1f8-1f1f1\"\n    },\n    {\n        \"alias\" : \"sk\",\n        \"unicode\" : \"1f1f8-1f1f0\"\n    },\n    {\n        \"alias\" : \"si\",\n        \"unicode\" : \"1f1f8-1f1ee\"\n    },\n    {\n        \"alias\" : \"sb\",\n        \"unicode\" : \"1f1f8-1f1e7\"\n    },\n    {\n        \"alias\" : \"so\",\n        \"unicode\" : \"1f1f8-1f1f4\"\n    },\n    {\n        \"alias\" : \"lk\",\n        \"unicode\" : \"1f1f1-1f1f0\"\n    },\n    {\n        \"alias\" : \"sd\",\n        \"unicode\" : \"1f1f8-1f1e9\"\n    },\n    {\n        \"alias\" : \"sr\",\n        \"unicode\" : \"1f1f8-1f1f7\"\n    },\n    {\n        \"alias\" : \"sz\",\n        \"unicode\" : \"1f1f8-1f1ff\"\n    },\n    {\n        \"alias\" : \"sy\",\n        \"unicode\" : \"1f1f8-1f1fe\"\n    },\n    {\n        \"alias\" : \"tw\",\n        \"unicode\" : \"1f1f9-1f1fc\"\n    },\n    {\n        \"alias\" : \"tj\",\n        \"unicode\" : \"1f1f9-1f1ef\"\n    },\n    {\n        \"alias\" : \"tz\",\n        \"unicode\" : \"1f1f9-1f1ff\"\n    },\n    {\n        \"alias\" : \"th\",\n        \"unicode\" : \"1f1f9-1f1ed\"\n    },\n    {\n        \"alias\" : \"tg\",\n        \"unicode\" : \"1f1f9-1f1ec\"\n    },\n    {\n        \"alias\" : \"to\",\n        \"unicode\" : \"1f1f9-1f1f4\"\n    },\n    {\n        \"alias\" : \"tt\",\n        \"unicode\" : \"1f1f9-1f1f9\"\n    },\n    {\n        \"alias\" : \"tn\",\n        \"unicode\" : \"1f1f9-1f1f3\"\n    },\n    {\n        \"alias\" : \"turkmenistan\",\n        \"unicode\" : \"1f1f9-1f1f2\"\n    },\n    {\n        \"alias\" : \"tuvalu\",\n        \"unicode\" : \"1f1f9-1f1fb\"\n    },\n    {\n        \"alias\" : \"vi\",\n        \"unicode\" : \"1f1fb-1f1ee\"\n    },\n    {\n        \"alias\" : \"ug\",\n        \"unicode\" : \"1f1fa-1f1ec\"\n    },\n    {\n        \"alias\" : \"ua\",\n        \"unicode\" : \"1f1fa-1f1e6\"\n    },\n    {\n        \"alias\" : \"uy\",\n        \"unicode\" : \"1f1fa-1f1fe\"\n    },\n    {\n        \"alias\" : \"uz\",\n        \"unicode\" : \"1f1fa-1f1ff\"\n    },\n    {\n        \"alias\" : \"vu\",\n        \"unicode\" : \"1f1fb-1f1fa\"\n    },\n    {\n        \"alias\" : \"va\",\n        \"unicode\" : \"1f1fb-1f1e6\"\n    },\n    {\n        \"alias\" : \"ve\",\n        \"unicode\" : \"1f1fb-1f1ea\"\n    },\n    {\n        \"alias\" : \"wf\",\n        \"unicode\" : \"1f1fc-1f1eb\"\n    },\n    {\n        \"alias\" : \"eh\",\n        \"unicode\" : \"1f1ea-1f1ed\"\n    },\n    {\n        \"alias\" : \"ye\",\n        \"unicode\" : \"1f1fe-1f1ea\"\n    },\n    {\n        \"alias\" : \"zm\",\n        \"unicode\" : \"1f1ff-1f1f2\"\n    }\n]\n"
  },
  {
    "path": "ui/src/main/resources/view/about/about.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.*?>\n<?import javafx.geometry.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.image.Image?>\n<?import javafx.scene.image.ImageView?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.text.*?>\n<VBox minWidth=\"320.0\" minHeight=\"260.0\" prefWidth=\"700.0\" prefHeight=\"480.0\" alignment=\"CENTER\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.about.AboutWindowController\">\n    <HBox VBox.vgrow=\"NEVER\">\n        <ImageView fx:id=\"logo\" fitHeight=\"78.0\" fitWidth=\"71.0\" pickOnBounds=\"true\" preserveRatio=\"true\">\n            <HBox.margin>\n                <Insets bottom=\"8.0\" left=\"8.0\" right=\"16.0\" top=\"8.0\"/>\n            </HBox.margin>\n            <Image url=\"@../../image/icon.png\"/>\n        </ImageView>\n        <VBox>\n            <Label text=\"Xeres\" styleClass=\"title-2\"/>\n            <HBox>\n                <Label text=\"%about.version\"/>\n                <Label text=\" \"/>\n                <Label fx:id=\"version\"/>\n            </HBox>\n            <Label fx:id=\"profile\">\n                <VBox.margin>\n                    <Insets top=\"4.0\"/>\n                </VBox.margin>\n            </Label>\n        </VBox>\n    </HBox>\n    <TabPane fx:id=\"infoPane\" prefHeight=\"256.0\" prefWidth=\"560.0\" tabClosingPolicy=\"UNAVAILABLE\" VBox.vgrow=\"ALWAYS\">\n        <Tab text=\"%about.title\">\n            <ScrollPane fitToWidth=\"true\">\n                <TextFlow>\n                    <padding>\n                        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                    </padding>\n                    <Text text=\"%about.slogan\" styleClass=\"text-caption\"/>\n                    <Text text=\"&#10;&#10;© 2019-2026 \"/>\n                    <Text text=\"%about.author-by\"/>\n                    <Text text=\" David Gerber, \"/>\n                    <Text text=\"%about.all-rights-reserved\"/>\n                    <Text text=\"&#10;&#10;\"/>\n                    <DisclosedHyperlink text=\"%about.report-bugs\" uri=\"https://github.com/zapek/Xeres/issues/new/choose\"/>\n                    <Text text=\"&#10;&#10;\"/>\n                    <DisclosedHyperlink text=\"%about.website\" uri=\"https://xeres.io\"/>\n                    <Text text=\"&#10;\"/>\n                    <DisclosedHyperlink text=\"%about.wiki\" uri=\"https://github.com/zapek/Xeres/wiki\"/>\n                    <Text text=\"&#10;\"/>\n                    <DisclosedHyperlink text=\"%about.source-code\" uri=\"https://github.com/zapek/Xeres\"/>\n                </TextFlow>\n            </ScrollPane>\n        </Tab>\n        <Tab text=\"%about.authors\">\n            <ScrollPane fitToWidth=\"true\">\n                <TextFlow>\n                    <padding>\n                        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                    </padding>\n                    <DisclosedHyperlink text=\"David Gerber (Zapek)\" uri=\"https://zapek.com/\"/>\n                    <Text text=\"&#10;\"/>\n                    <DisclosedHyperlink text=\"dg@zapek.com\" uri=\"mailto:dg@zapek.com\"/>\n                    <Text text=\"&#10;Benevolent dictator, lead developer\" styleClass=\"text-italic\"/>\n                </TextFlow>\n            </ScrollPane>\n        </Tab>\n        <Tab text=\"%about.thanks\">\n            <ScrollPane fitToWidth=\"true\">\n                <TextFlow>\n                    <padding>\n                        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                    </padding>\n                    <DisclosedHyperlink text=\"Olivier Piras\" uri=\"https://github.com/oprs\"/>\n                    <Text text=\"&#10;for coming up with the name&#10;&#10;\"/>\n                    <Text text=\"Nicolas Dirand&#10;for raising a statue in my image in his village&#10;&#10;\"/>\n                    <DisclosedHyperlink text=\"Cyril Soler\" uri=\"https://github.com/csoler\"/>\n                    <Text text=\"&#10;for answering my silly questions&#10;&#10;\"/>\n                    <Text text=\"Adrien Gerber&#10;for testing the emojis&#10;&#10;\"/>\n                    <DisclosedHyperlink text=\"The Retroshare developers\" uri=\"https://retroshare.cc/\"/>\n                    <Text text=\"&#10;for coming up with the most interesting P2P concepts\"/>\n                </TextFlow>\n            </ScrollPane>\n        </Tab>\n        <Tab text=\"%about.license\">\n            <ScrollPane fitToWidth=\"true\">\n                <TextFlow>\n                    <padding>\n                        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                    </padding>\n                    <Text fx:id=\"license\" styleClass=\"fixed-font\"/>\n                </TextFlow>\n            </ScrollPane>\n        </Tab>\n        <Tab text=\"%about.additional-licenses\">\n            <ScrollPane fitToWidth=\"true\">\n                <TextFlow>\n                    <padding>\n                        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                    </padding>\n                    <Text text=\"This software contains unmodified binary redistributions of the following other software:\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Apache Commons Collections\" uri=\"https://commons.apache.org/proper/commons-collections/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © The Apache Software Foundation. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Apache Commons IO\" uri=\"https://commons.apache.org/proper/commons-lang/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © The Apache Software Foundation. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Apache Commons Lang\" uri=\"https://commons.apache.org/proper/commons-lang/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © The Apache Software Foundation. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"AppDirs\" uri=\"https://github.com/harawata/appdirs\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Iwao Ave. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"AtlantaFX\" uri=\"https://mkpaz.github.io/atlantafx\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © mkpaz. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MIT license.\" uri=\"https://github.com/mkpaz/atlantafx?tab=MIT-1-ov-file#readme\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Bloomfilter\" uri=\"https://github.com/sangupta/bloomfilter\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Sandeep Gupta. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Bouncy Castle\" uri=\"https://www.bouncycastle.org/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © The Legion of the Bouncy Castle Inc. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MIT license.\" uri=\"https://www.bouncycastle.org/about/license/#License\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Commonmark Java\" uri=\"https://github.com/commonmark/commonmark-java\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Robin Stocker. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"BSD 2-Clause license.\" uri=\"https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Contact Identicons\" uri=\"https://github.com/davidhampgonsalves/Contact-Identicons\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © David Hamp-Gonsalves. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MIT license.\" uri=\"https://github.com/davidhampgonsalves/Contact-Identicons?tab=MIT-1-ov-file\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Flowless\" uri=\"https://github.com/FXMisc/Flowless\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Tomas Mikula. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"BSD 2-Clause license.\" uri=\"https://github.com/FXMisc/Flowless/blob/master/LICENSE\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Flyway\" uri=\"https://www.red-gate.com/products/flyway/community/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © RedGate. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"GeoLite2 Country\" uri=\"https://www.maxmind.com/en/geoip-databases\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © MaxMind. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"GeoLite 2 End User License Agreement\" uri=\"https://www.maxmind.com/en/geolite2/eula\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"GraalVM\" uri=\"https://www.graalvm.org/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Oracle. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"GFTC license.\" uri=\"https://www.oracle.com/downloads/licenses/graal-free-license.html\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"H2 Database Engine\" uri=\"https://h2database.com/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © H2 Group. Dual licensed under the \"/>\n                    <DisclosedHyperlink text=\"MPL 2.0 or EPL 1.0.\" uri=\"https://h2database.com/html/license.html\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Ikonli\" uri=\"https://github.com/kordamp/ikonli\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Andres Almiray. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"imgscalr\" uri=\"https://github.com/rkalla/imgscalr\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Riyad Kalla. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"JavaFX\" uri=\"https://openjfx.io/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Oracle. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"GPLv2 + classpath exception.\" uri=\"https://openjdk.org/legal/gplv2+ce.html\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"JavaFX-Weaver\" uri=\"https://github.com/rgielen/javafx-weaver\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © René Gielen. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Java Native Access\" uri=\"https://github.com/java-native-access/jna\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Timothy Wall and others. Dual licensed under the \"/>\n                    <DisclosedHyperlink text=\"LGPL\" uri=\"https://www.gnu.org/licenses/lgpl-3.0.en.html\"/>\n                    <Text text=\" or \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Joy JSON Processing API\" uri=\"https://github.com/java-json-tools/json-patch\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © the original authors. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"GPLv3\" uri=\"https://www.gnu.org/licenses/lgpl-3.0.txt\"/>\n                    <Text text=\" or \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"JSpeex\" uri=\"https://github.com/SourceUtils/jspeex\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Wimba S.A. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"BSD license.\" uri=\"https://github.com/SourceUtils/jspeex?tab=License-1-ov-file#readme\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"jsoup\" uri=\"https://jsoup.org/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Jonathan Hedley. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MIT license.\" uri=\"https://github.com/jhy/jsoup?tab=MIT-1-ov-file#readme\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Material Design 2\" uri=\"https://m2.material.io/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Google. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"mldht\" uri=\"https://github.com/the8472/mldht\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © the8472. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MPL 2.0.\" uri=\"https://www.mozilla.org/en-US/MPL/\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Multi Platform Amiga Fonts\" uri=\"https://github.com/rewtnull/amigafonts\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © dMG/t!s^dS!. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"GPL-FE license.\" uri=\"https://www.gnu.org/licenses/gpl-faq.html#FontException\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Netty\" uri=\"https://netty.io/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © The Netty project. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Pngtastic\" uri=\"https://github.com/depsypher/pngtastic\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Ray Vanderborgh. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MIT license.\" uri=\"https://github.com/depsypher/pngtastic/blob/master/LICENSE.txt\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Sound Effects\" uri=\"https://pixabay.com/sound-effects\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Pixabay. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Creative Commons Zero (CC0) license.\" uri=\"https://pixabay.com/service/terms/\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Spring Boot\" uri=\"https://spring.io/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Broadcom. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"springdoc-openapi\" uri=\"https://springdoc.org/\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © the original authors. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://github.com/springdoc/springdoc-openapi/blob/main/LICENSE\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"TwelveMonkeys\" uri=\"https://github.com/haraldk/TwelveMonkeys\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Harald Kuhr. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"BSD 3-Clause license.\" uri=\"https://github.com/haraldk/TwelveMonkeys/blob/master/LICENSE.txt\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Twemoji\" uri=\"https://github.com/jdecked/twemoji\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Twitter, Inc and other contributors. Graphics licensed under \"/>\n                    <DisclosedHyperlink text=\"CC-BY 4.0.\" uri=\"https://creativecommons.org/licenses/by/4.0/\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"Webcam Capture API\" uri=\"https://github.com/sarxos/webcam-capture/tree/master\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © Bartosz Firyn and Contributors. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"MIT license.\" uri=\"https://github.com/sarxos/webcam-capture/blob/master/LICENSE.txt\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                    <DisclosedHyperlink text=\"ZXing (Zebra Crossing)\" uri=\"https://github.com/zxing/zxing\"/>\n                    <Text text=\"&#10;\"/>\n                    <Text text=\" © the original authors. Licensed under the \"/>\n                    <DisclosedHyperlink text=\"Apache 2.0 license.\" uri=\"https://www.apache.org/licenses/LICENSE-2.0\"/>\n                    <Text text=\"&#10;&#10;\"/>\n\n                </TextFlow>\n            </ScrollPane>\n        </Tab>\n    </TabPane>\n    <HBox alignment=\"TOP_RIGHT\">\n        <VBox.margin>\n            <Insets top=\"8.0\"/>\n        </VBox.margin>\n        <Button fx:id=\"closeWindow\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%close\"/>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/account/account_creation.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox alignment=\"CENTER\" minHeight=\"380.0\" minWidth=\"280.0\" prefHeight=\"380.0\" prefWidth=\"360.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.account.AccountCreationWindowController\" styleClass=\"base-spacing\">\n    <HBox alignment=\"BASELINE_RIGHT\">\n        <Button styleClass=\"button-icon, flat, accent\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2i-information\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDelay=\"0ms\" showDuration=\"5m\" maxWidth=\"400\" wrapText=\"true\" text=\"%account.welcome.tip\"/>\n            </tooltip>\n        </Button>\n    </HBox>\n    <Label alignment=\"CENTER\" text=\"%account.welcome\" styleClass=\"title-1\">\n        <VBox.margin>\n            <Insets bottom=\"12.0\"/>\n        </VBox.margin>\n    </Label>\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%profile\"/>\n        <TextField fx:id=\"profileName\" promptText=\"%account.profile.prompt\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%account.profile.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%account.location\" GridPane.rowIndex=\"1\"/>\n        <TextField fx:id=\"locationName\" promptText=\"%account.location.prompt\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%account.location.tip\"/>\n            </tooltip>\n        </TextField>\n    </GridPane>\n    <TitledPane fx:id=\"titledPane\" style=\"-fx-padding: 8 0 0 0\" styleClass=\"dense\" expanded=\"false\" text=\"%account.options\" VBox.vgrow=\"ALWAYS\">\n        <HBox>\n            <Button fx:id=\"importBackup\" text=\"%account.generation.import\">\n                <tooltip>\n                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%account.generation.import.tip\"/>\n                </tooltip>\n            </Button>\n        </HBox>\n    </TitledPane>\n    <ProgressIndicator fx:id=\"progress\" minWidth=\"28.0\" prefWidth=\"28.0\" prefHeight=\"28.0\" minHeight=\"28.0\" visible=\"false\">\n        <VBox.margin>\n            <Insets top=\"4.0\"/>\n        </VBox.margin>\n    </ProgressIndicator>\n    <Label fx:id=\"status\"/>\n    <Spacer VBox.vgrow=\"ALWAYS\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <VBox.margin>\n            <Insets top=\"8.0\"/>\n        </VBox.margin>\n        <Button fx:id=\"helpButton\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%help\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2l-lifebuoy\"/>\n            </graphic>\n        </Button>\n        <Spacer HBox.hgrow=\"ALWAYS\"/>\n        <Button fx:id=\"okButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%create\"/>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/board/board_group_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.ImageSelectorView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefHeight=\"290.0\" prefWidth=\"400.0\" minHeight=\"-Infinity\" minWidth=\"-Infinity\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.board.BoardGroupWindowController\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%name\"/>\n        <TextField fx:id=\"boardName\" promptText=\"%board.create.name.prompt\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%board.create.name.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%description\" GridPane.rowIndex=\"1\"/>\n        <TextField fx:id=\"boardDescription\" promptText=\"%board.create.description.prompt\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%board.create.description.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%logo\" GridPane.rowIndex=\"2\"/>\n        <HBox GridPane.rowIndex=\"2\" GridPane.columnIndex=\"1\">\n            <ImageSelectorView fx:id=\"boardLogo\" fitWidth=\"128\" fitHeight=\"128\" placeholder=\"mdi2i-image\"/>\n            <Spacer/>\n        </HBox>\n    </GridPane>\n    <Region VBox.vgrow=\"ALWAYS\"/>\n    <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\" managed=\"false\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"createOrUpdateButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%create\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n\n"
  },
  {
    "path": "ui/src/main/resources/view/board/board_message_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Tab?>\n<?import atlantafx.base.controls.TabLine?>\n<?import io.xeres.ui.custom.EditorView?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefWidth=\"640.0\" prefHeight=\"490.0\" minWidth=\"320.0\" minHeight=\"460.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <GridPane fx:id=\"gridPane\" hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%board.editor.name\"/>\n        <ReadOnlyTextField fx:id=\"boardName\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%board.editor.name.prompt\"/>\n            </tooltip>\n        </ReadOnlyTextField>\n\n        <TabLine fx:id=\"tabLine\" GridPane.rowIndex=\"1\" GridPane.columnIndex=\"1\">\n            <tabs>\n                <Tab fx:id=\"textTab\" text=\"%text\"/>\n                <Tab fx:id=\"imageTab\" text=\"%image\"/>\n                <Tab fx:id=\"linkTab\" text=\"%link\"/>\n            </tabs>\n        </TabLine>\n\n        <Label text=\"%board.editor.thread.title\" GridPane.rowIndex=\"2\"/>\n        <TextField fx:id=\"title\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%board.editor.post.description\"/>\n            </tooltip>\n        </TextField>\n\n        <!-- variable content here -->\n\n    </GridPane>\n\n    <EditorView fx:id=\"editorView\" prompt=\"%body\" VBox.vgrow=\"SOMETIMES\">\n        <VBox.margin>\n            <Insets top=\"8.0\"/>\n        </VBox.margin>\n    </EditorView>\n    <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\" managed=\"false\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"send\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%send\">\n            <HBox.margin>\n                <Insets top=\"8.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/board/board_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.controller.common.GxsGroupTreeTableView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox alignment=\"CENTER\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.board.BoardViewController\">\n    <SplitPane fx:id=\"splitPaneVertical\" dividerPositions=\"0.1\" VBox.vgrow=\"ALWAYS\">\n        <VBox SplitPane.resizableWithParent=\"false\" VBox.vgrow=\"ALWAYS\">\n            <HBox alignment=\"CENTER_RIGHT\" minHeight=\"-Infinity\" VBox.vgrow=\"NEVER\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"createBoard\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-folder-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%board.view.create.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <GxsGroupTreeTableView fx:id=\"boardTree\" VBox.vgrow=\"ALWAYS\"/>\n        </VBox>\n        <VBox VBox.vgrow=\"ALWAYS\">\n            <HBox>\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"newPost\" disable=\"true\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-pencil-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.view.new-message.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <StackPane fx:id=\"contentGroup\" VBox.vgrow=\"ALWAYS\" styleClass=\"panel-border\"/>\n        </VBox>\n    </SplitPane>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/board/message_cell.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.asyncimage.AsyncImageView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.StackPane?>\n<?import javafx.scene.layout.VBox?>\n<?import javafx.scene.text.TextFlow?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox fx:id=\"groupView\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" styleClass=\"board-cell\">\n    <HBox>\n        <Label text=\"%board.posted-by\"/>\n        <Label text=\" \"/>\n        <Label fx:id=\"authorLabel\"/>\n        <Label text=\" \"/>\n        <Label text=\"%board.on\"/>\n        <Label text=\" \"/>\n        <Label fx:id=\"postInstantLabel\"/>\n        <ToggleButton fx:id=\"unreadButton\" styleClass=\"small-toggle, flat\">\n            <HBox.margin>\n                <Insets left=\"8.0\"/>\n            </HBox.margin>\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2c-checkbox-marked-outline\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%mark-read-unread\"/>\n            </tooltip>\n        </ToggleButton>\n    </HBox>\n    <TextFlow fx:id=\"titleFlow\" styleClass=\"title-4\"/>\n    <TextFlow fx:id=\"contentFlow\" managed=\"false\"/>\n    <StackPane alignment=\"CENTER_LEFT\">\n        <Spacer minHeight=\"80\"/>\n        <AsyncImageView fx:id=\"imageView\" managed=\"false\"/>\n    </StackPane>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/channel/channel_group_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.ImageSelectorView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefHeight=\"290.0\" prefWidth=\"400.0\" minHeight=\"-Infinity\" minWidth=\"-Infinity\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.channel.ChannelGroupWindowController\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%name\"/>\n        <TextField fx:id=\"channelName\" promptText=\"%channel.create.name.prompt\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.create.name.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%description\" GridPane.rowIndex=\"1\"/>\n        <TextField fx:id=\"channelDescription\" promptText=\"%channel.create.description.prompt\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.create.description.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%logo\" GridPane.rowIndex=\"2\"/>\n        <HBox GridPane.rowIndex=\"2\" GridPane.columnIndex=\"1\">\n            <ImageSelectorView fx:id=\"channelLogo\" fitWidth=\"128\" fitHeight=\"128\" placeholder=\"mdi2i-image\"/>\n            <Spacer/>\n        </HBox>\n    </GridPane>\n    <Region VBox.vgrow=\"ALWAYS\"/>\n    <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\" managed=\"false\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"createOrUpdateButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%create\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/channel/channel_message_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.EditorView?>\n<?import io.xeres.ui.custom.ImageSelectorView?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.*?>\n<VBox alignment=\"CENTER\" prefWidth=\"640.0\" prefHeight=\"490.0\" minWidth=\"320.0\" minHeight=\"480.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"SOMETIMES\"/>\n        </rowConstraints>\n        <Label text=\"%channel.editor.name\"/>\n        <ReadOnlyTextField fx:id=\"channelName\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.editor.name.prompt\"/>\n            </tooltip>\n        </ReadOnlyTextField>\n    </GridPane>\n    <TabPane fx:id=\"tabPane\" tabClosingPolicy=\"UNAVAILABLE\" VBox.vgrow=\"SOMETIMES\">\n        <VBox.margin>\n            <Insets top=\"8.0\"/>\n        </VBox.margin>\n        <Tab fx:id=\"postTab\" text=\"%channel.post\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2p-post\"/>\n            </graphic>\n            <GridPane hgap=\"8\" vgap=\"8\">\n                <padding>\n                    <Insets top=\"12.0\"/>\n                </padding>\n                <columnConstraints>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                    <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                </columnConstraints>\n                <rowConstraints>\n                    <RowConstraints vgrow=\"SOMETIMES\"/>\n                    <RowConstraints vgrow=\"SOMETIMES\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                </rowConstraints>\n                <Label text=\"%channel.editor.thread.title\"/>\n                <TextField fx:id=\"title\" GridPane.columnIndex=\"1\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.editor.post.description\"/>\n                    </tooltip>\n                </TextField>\n\n                <Label text=\"%thumbnail\" GridPane.rowIndex=\"1\"/>\n                <HBox GridPane.rowIndex=\"1\" GridPane.columnIndex=\"1\">\n                    <ImageSelectorView fx:id=\"postLogo\" fitWidth=\"128\" fitHeight=\"128\" placeholder=\"mdi2i-image\"/>\n                    <Spacer/>\n                </HBox>\n\n                <EditorView fx:id=\"editorView\" prompt=\"%body\" GridPane.rowIndex=\"2\" GridPane.columnSpan=\"2\" VBox.vgrow=\"SOMETIMES\">\n                    <VBox.margin>\n                        <Insets top=\"8.0\"/>\n                    </VBox.margin>\n                </EditorView>\n            </GridPane>\n        </Tab>\n        <Tab fx:id=\"attachmentsTab\" text=\"%channel.files\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2a-attachment\"/>\n            </graphic>\n            <VBox VBox.vgrow=\"ALWAYS\">\n                <TableView fx:id=\"channelFileTableView\" VBox.vgrow=\"ALWAYS\">\n                    <VBox.margin>\n                        <Insets top=\"12.0\"/>\n                    </VBox.margin>\n                    <placeholder>\n                        <Label text=\"%channel.drag-drop\"/>\n                    </placeholder>\n                    <columnResizePolicy>\n                        <TableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n                    </columnResizePolicy>\n                    <columns>\n                        <TableColumn fx:id=\"tableName\" minWidth=\"320\" text=\"%name\"/>\n                        <TableColumn fx:id=\"tableState\" minWidth=\"80\" text=\"%state\"/>\n                        <TableColumn fx:id=\"tableSize\" minWidth=\"100\" prefWidth=\"100\" maxWidth=\"100\" text=\"%size\"/>\n                        <TableColumn fx:id=\"tableHash\" minWidth=\"100\" prefWidth=\"320.0\" text=\"%hash\"/>\n                    </columns>\n                </TableView>\n                <HBox spacing=\"4.0\">\n                    <VBox.margin>\n                        <Insets top=\"4.0\"/>\n                    </VBox.margin>\n                    <Button fx:id=\"addFile\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-file-document-plus\"/>\n                        </graphic>\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.add-files\"/>\n                        </tooltip>\n                    </Button>\n                    <Button fx:id=\"pasteLink\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2c-content-copy\"/>\n                        </graphic>\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.paste-links\"/>\n                        </tooltip>\n                    </Button>\n                    <Button fx:id=\"removeFile\" disable=\"true\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-file-document-remove\"/>\n                        </graphic>\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.remove-files\"/>\n                        </tooltip>\n                    </Button>\n                </HBox>\n            </VBox>\n        </Tab>\n    </TabPane>\n    <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\" managed=\"false\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"send\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%send\">\n            <HBox.margin>\n                <Insets top=\"8.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/channel/channel_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.controller.common.GxsGroupTreeTableView?>\n<?import io.xeres.ui.custom.InfoView?>\n<?import io.xeres.ui.custom.ProgressPane?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox alignment=\"CENTER\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.channel.ChannelViewController\">\n    <SplitPane fx:id=\"splitPaneVertical\" dividerPositions=\"0.1\" VBox.vgrow=\"ALWAYS\">\n        <VBox SplitPane.resizableWithParent=\"false\" VBox.vgrow=\"ALWAYS\">\n            <HBox alignment=\"CENTER_RIGHT\" minHeight=\"-Infinity\" VBox.vgrow=\"NEVER\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"createChannel\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-folder-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%channel.view.create.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <GxsGroupTreeTableView fx:id=\"channelTree\" VBox.vgrow=\"ALWAYS\"/>\n        </VBox>\n        <VBox VBox.vgrow=\"ALWAYS\">\n            <HBox>\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"newPost\" disable=\"true\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-pencil-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.view.new-message.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <SplitPane fx:id=\"splitPaneHorizontal\" dividerPositions=\"0.3\" orientation=\"VERTICAL\" VBox.vgrow=\"ALWAYS\">\n                <StackPane styleClass=\"panel-border\">\n                    <ProgressPane fx:id=\"channelMessagesProgress\"/>\n                </StackPane>\n                <InfoView fx:id=\"infoView\" VBox.vgrow=\"ALWAYS\"/>\n            </SplitPane>\n        </VBox>\n    </SplitPane>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/channel/message_cell.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.asyncimage.PlaceholderImageView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.Label?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<HBox fx:id=\"groupView\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" styleClass=\"channel-cell\">\n    <PlaceholderImageView fx:id=\"imageView\" fitWidth=\"64.0\" fitHeight=\"64.0\"/>\n    <VBox>\n        <HBox.margin>\n            <Insets left=\"4.0\"/>\n        </HBox.margin>\n        <Label fx:id=\"titleLabel\" wrapText=\"true\"/>\n        <Label fx:id=\"postInstantLabel\"/>\n    </VBox>\n</HBox>"
  },
  {
    "path": "ui/src/main/resources/view/chat/chat_roominfo.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.chat.ChatRoomInfoController\">\n    <padding>\n        <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"8.0\"/>\n    </padding>\n    <GridPane fx:id=\"roomGroup\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n            <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n            <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n            <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n            <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n        </rowConstraints>\n        <Label text=\"%name\"/>\n        <Label fx:id=\"roomName\" GridPane.columnIndex=\"1\"/>\n        <Label text=\"%chat.room.id\" GridPane.rowIndex=\"1\"/>\n        <Label fx:id=\"roomId\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\"/>\n        <Label text=\"%chat.room.topic\" GridPane.rowIndex=\"2\"/>\n        <Label fx:id=\"roomTopic\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\"/>\n        <Label text=\"%chat.room.security\" GridPane.rowIndex=\"3\"/>\n        <Label fx:id=\"roomSecurity\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"3\"/>\n        <Label text=\"%chat.room.users\" GridPane.rowIndex=\"4\"/>\n        <Label fx:id=\"roomCount\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"4\"/>\n    </GridPane>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/chat/chat_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.InputAreaGroup?>\n<?import io.xeres.ui.custom.ResizeableImageView?>\n<?import io.xeres.ui.custom.TypingNotificationView?>\n<?import javafx.geometry.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox alignment=\"CENTER\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.chat.ChatViewController\">\n    <SplitPane fx:id=\"splitPane\" dividerPositions=\"0.1, 0.8\" VBox.vgrow=\"ALWAYS\">\n        <VBox SplitPane.resizableWithParent=\"false\" VBox.vgrow=\"ALWAYS\">\n            <HBox alignment=\"CENTER_RIGHT\" minHeight=\"-Infinity\" VBox.vgrow=\"NEVER\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"createChatRoom\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2c-chat-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%chat.room.create.tooltip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <TreeView fx:id=\"roomTree\" minWidth=\"-Infinity\" prefWidth=\"200.0\" VBox.vgrow=\"ALWAYS\"/>\n        </VBox>\n        <VBox minWidth=\"200.0\" VBox.vgrow=\"ALWAYS\">\n            <HBox fx:id=\"status\" alignment=\"CENTER_LEFT\" visible=\"false\" VBox.vgrow=\"NEVER\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Label fx:id=\"roomName\">\n                    <HBox.margin>\n                        <Insets left=\"4.0\" right=\"8.0\"/>\n                    </HBox.margin>\n                </Label>\n                <Label fx:id=\"roomTopic\" ellipsisString=\"\" style=\"-fx-font-style: italic\">\n                    <HBox.margin>\n                        <Insets left=\"8.0\"/>\n                    </HBox.margin>\n                </Label>\n                <Region HBox.hgrow=\"ALWAYS\"/>\n                <Button fx:id=\"invite\" mnemonicParsing=\"false\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2a-account-multiple-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%chat.room.invite.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <VBox fx:id=\"content\" VBox.vgrow=\"ALWAYS\">\n                <VBox fx:id=\"sendGroup\">\n                    <HBox fx:id=\"previewGroup\">\n                        <VBox.margin>\n                            <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"8.0\"/>\n                        </VBox.margin>\n                        <ResizeableImageView fx:id=\"imagePreview\" pickOnBounds=\"true\" preserveRatio=\"true\"/>\n                        <VBox>\n                            <Button fx:id=\"previewCancel\" mnemonicParsing=\"false\" text=\"%cancel\" minWidth=\"-Infinity\"/><!-- prevents ellipsis when resizing to minimum -->\n                            <Region VBox.vgrow=\"ALWAYS\"/>\n                            <Button fx:id=\"previewSend\" mnemonicParsing=\"false\" text=\"%send\" minWidth=\"-Infinity\"/>\n                            <HBox.margin>\n                                <Insets left=\"8.0\"/>\n                            </HBox.margin>\n                        </VBox>\n                    </HBox>\n                    <TypingNotificationView fx:id=\"typingNotification\">\n                        <VBox.margin>\n                            <Insets bottom=\"4.0\" left=\"8.0\" top=\"4.0\"/>\n                        </VBox.margin>\n                    </TypingNotificationView>\n                    <InputAreaGroup fx:id=\"send\">\n                        <VBox.margin>\n                            <Insets top=\"4.0\"/>\n                        </VBox.margin>\n                    </InputAreaGroup>\n                </VBox>\n            </VBox>\n        </VBox>\n        <VBox fx:id=\"userListContent\" minWidth=\"-Infinity\" prefHeight=\"200.0\" prefWidth=\"200.0\" SplitPane.resizableWithParent=\"false\">\n        </VBox>\n    </SplitPane>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/chat/chatroom_create.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" minHeight=\"-Infinity\" minWidth=\"-Infinity\" prefHeight=\"244.0\" prefWidth=\"400.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.chat.ChatRoomCreationWindowController\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%name\"/>\n        <TextField fx:id=\"roomName\" promptText=\"%chat.room.create.name.prompt\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%chat.room.create.name.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%chat.room.topic\" GridPane.rowIndex=\"1\"/>\n        <TextField fx:id=\"topic\" promptText=\"%chat.room.create.topic.prompt\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%chat.room.create.topic.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%chat.room.create.visibility\" GridPane.rowIndex=\"2\"/>\n        <ChoiceBox fx:id=\"visibility\" prefWidth=\"150.0\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%chat.room.create.visibility.tip\"/>\n            </tooltip>\n        </ChoiceBox>\n        <Label text=\"%chat.room.security\" GridPane.rowIndex=\"3\"/>\n        <CheckBox fx:id=\"security\" mnemonicParsing=\"false\" text=\"%chat.room.create.security.checkbox\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"3\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%chat.room.create.security.tip\"/>\n            </tooltip>\n        </CheckBox>\n    </GridPane>\n    <Region VBox.vgrow=\"ALWAYS\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"createButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%create\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/chat/chatroom_invite.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.TreeView?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" maxHeight=\"-Infinity\" maxWidth=\"-Infinity\" minHeight=\"-Infinity\" minWidth=\"-Infinity\" prefHeight=\"244.0\" prefWidth=\"400.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.chat.ChatRoomInvitationWindowController\">\n    <TreeView fx:id=\"peersTree\" maxHeight=\"1.7976931348623157E308\" maxWidth=\"1.7976931348623157E308\" VBox.vgrow=\"ALWAYS\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"inviteButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%chat.room.invite.button\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <VBox.margin>\n            <Insets top=\"12.0\"/>\n        </VBox.margin>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/contact/contact_view.fxml",
    "content": "<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.CustomTextField?>\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.asyncimage.AsyncImageView?>\n<?import io.xeres.ui.custom.ImageSelectorView?>\n<?import javafx.geometry.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.shape.Circle?>\n<?import org.kordamp.ikonli.javafx.*?>\n<VBox alignment=\"CENTER\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.contact.ContactViewController\">\n    <SplitPane dividerPositions=\"0.3\" VBox.vgrow=\"ALWAYS\">\n        <VBox SplitPane.resizableWithParent=\"false\" VBox.vgrow=\"NEVER\">\n            <HBox fx:id=\"ownContactGroup\" styleClass=\"group-emphasis, group-frame\" alignment=\"CENTER_LEFT\">\n                <padding>\n                    <Insets top=\"8.0\" bottom=\"8.0\" left=\"8.0\"/>\n                </padding>\n                <StackPane alignment=\"TOP_LEFT\">\n                    <HBox.margin>\n                        <Insets right=\"8.0\"/>\n                    </HBox.margin>\n                    <Circle fx:id=\"ownContactCircle\" radius=\"24\" StackPane.alignment=\"CENTER_LEFT\" visible=\"false\"/>\n                    <AsyncImageView fx:id=\"ownContactImageView\" fitWidth=\"48\" fitHeight=\"48\" visible=\"false\"/>\n                    <Circle fx:id=\"ownContactState\" radius=\"8\" StackPane.alignment=\"BOTTOM_RIGHT\" fill=\"lawngreen\"/>\n                </StackPane>\n                <Label fx:id=\"ownContactName\" styleClass=\"title-4\"/>\n            </HBox>\n            <HBox alignment=\"CENTER_LEFT\" minHeight=\"-Infinity\" VBox.vgrow=\"NEVER\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <CustomTextField fx:id=\"searchTextField\" promptText=\"%contact-view.search.prompt\" HBox.hgrow=\"ALWAYS\"/>\n                <MenuButton styleClass=\"no-arrow, flat\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-filter-menu\"/>\n                    </graphic>\n                    <items>\n                        <CheckMenuItem fx:id=\"showAllContacts\" text=\"%contact-view.search.show-all\" selected=\"true\"/>\n                    </items>\n                </MenuButton>\n            </HBox>\n            <TreeTableView fx:id=\"contactTreeTableView\" minWidth=\"-Infinity\" prefWidth=\"200\" VBox.vgrow=\"ALWAYS\">\n                <placeholder>\n                    <Label text=\"%contact-view.search.no-contacts\"/>\n                </placeholder>\n                <columns>\n                    <TreeTableColumn fx:id=\"contactTreeTableNameColumn\" prefWidth=\"180.0\" text=\"%name\"/>\n                    <TreeTableColumn fx:id=\"contactTreeTablePresenceColumn\" prefWidth=\"40.0\"/>\n                </columns>\n            </TreeTableView>\n        </VBox>\n        <VBox>\n            <padding>\n                <Insets right=\"4.0\" top=\"4.0\"/>\n            </padding>\n            <HBox fx:id=\"detailsHeader\" visible=\"false\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"8.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <ImageSelectorView fx:id=\"contactImageSelectorView\" fitWidth=\"128.0\" fitHeight=\"128.0\"/>\n                <HBox HBox.hgrow=\"ALWAYS\">\n                    <HBox.margin>\n                        <Insets left=\"8.0\"/>\n                    </HBox.margin>\n                    <VBox>\n                        <Label fx:id=\"nameLabel\" style=\"-fx-font-size: 2.5em\"/>\n                        <Label fx:id=\"badgeOwn\" text=\"%contact-view.badge.own\" styleClass=\"accent\" managed=\"false\" visible=\"false\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2l-label\"/>\n                            </graphic>\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%contact-view.badge.own.tip\"/>\n                            </tooltip>\n                        </Label>\n                        <Label fx:id=\"badgePartial\" text=\"%contact-view.badge.partial\" styleClass=\"warning\" managed=\"false\" visible=\"false\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2l-label\"/>\n                            </graphic>\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%contact-view.badge.partial.tip\"/>\n                            </tooltip>\n                        </Label>\n                        <Label fx:id=\"badgeAccepted\" text=\"%contact-view.badge.accepted\" managed=\"false\" visible=\"false\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2l-label\"/>\n                            </graphic>\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%contact-view.badge.accepted.tip\"/>\n                            </tooltip>\n                        </Label>\n                        <Label fx:id=\"badgeUnvalidated\" text=\"%contact-view.badge.not-validated\" styleClass=\"warning\" managed=\"false\" visible=\"false\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2l-label\"/>\n                            </graphic>\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\"\n                                         text=\"%contact-view.badge.not-validated.tip\"/>\n                            </tooltip>\n                        </Label>\n                    </VBox>\n                    <Spacer HBox.hgrow=\"ALWAYS\"/>\n                    <VBox>\n                        <Button fx:id=\"chatButton\" disable=\"true\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2m-message\"/>\n                            </graphic>\n                        </Button>\n                    </VBox>\n                </HBox>\n            </HBox>\n            <VBox fx:id=\"detailsView\" visible=\"false\" VBox.vgrow=\"ALWAYS\">\n                <padding>\n                    <Insets right=\"4.0\" top=\"4.0\" left=\"12.0\" bottom=\"4.0\"/>\n                </padding>\n                <GridPane>\n                    <columnConstraints>\n                        <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                        <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"280.0\"/>\n                    </columnConstraints>\n                    <rowConstraints>\n                        <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n                        <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n                        <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n                    </rowConstraints>\n                    <Label text=\"ID\"/>\n                    <Label fx:id=\"idLabel\" GridPane.columnIndex=\"1\"/>\n                    <Label text=\"%contact-view.information.type\" GridPane.rowIndex=\"1\"/>\n                    <Label fx:id=\"typeLabel\" GridPane.rowIndex=\"1\" GridPane.columnIndex=\"1\"/>\n                    <Label fx:id=\"createdOrUpdated\" text=\"%contact-view.information.created\" GridPane.rowIndex=\"2\"/>\n                    <Label fx:id=\"createdLabel\" GridPane.rowIndex=\"2\" GridPane.columnIndex=\"1\"/>\n                </GridPane>\n                <GridPane fx:id=\"profilePane\" visible=\"false\">\n                    <columnConstraints>\n                        <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                        <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"280.0\"/>\n                    </columnConstraints>\n                    <rowConstraints>\n                        <RowConstraints minHeight=\"10.0\" prefHeight=\"30.0\" vgrow=\"SOMETIMES\"/>\n                    </rowConstraints>\n                    <Label fx:id=\"trustLabel\" text=\"%trust\"/>\n                    <ChoiceBox fx:id=\"trust\" GridPane.columnIndex=\"1\" minWidth=\"150.0\">\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.trust.tip\"/>\n                        </tooltip>\n                    </ChoiceBox>\n                </GridPane>\n                <VBox fx:id=\"locationsView\" visible=\"false\">\n                    <VBox.margin>\n                        <Insets top=\"16.0\"/>\n                    </VBox.margin>\n                    <Label text=\"%contact-view.information.locations\" styleClass=\"title-4\"/>\n                    <TableView fx:id=\"locationTableView\" prefHeight=\"130.0\">\n                        <VBox.margin>\n                            <Insets top=\"8.0\"/>\n                        </VBox.margin>\n                        <columnResizePolicy>\n                            <TableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n                        </columnResizePolicy>\n                        <columns>\n                            <TableColumn fx:id=\"locationTableNameColumn\" text=\"%name\"/>\n                            <TableColumn fx:id=\"locationTablePresenceColumn\" minWidth=\"35.0\" prefWidth=\"35.0\" maxWidth=\"35.0\"/>\n                            <TableColumn fx:id=\"locationTableIPColumn\" minWidth=\"120.0\" prefWidth=\"120.0\" maxWidth=\"120.0\" text=\"%ip\"/>\n                            <TableColumn fx:id=\"locationTablePortColumn\" minWidth=\"60.0\" prefWidth=\"60.0\" maxWidth=\"60.0\" text=\"%port\"/>\n                            <TableColumn fx:id=\"locationTableLastConnectedColumn\" minWidth=\"100.0\" prefWidth=\"140.0\" maxWidth=\"140.0\" text=\"%contact-view.column.last-connected\"/>\n                        </columns>\n                    </TableView>\n                </VBox>\n            </VBox>\n        </VBox>\n    </SplitPane>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/custom/alias_view.fxml",
    "content": "<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.control.ListView?>\n<?import javafx.scene.layout.VBox?>\n<fx:root type=\"VBox\" minWidth=\"200.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" styleClass=\"popup-window\">\n    <ListView fx:id=\"aliasList\" VBox.vgrow=\"ALWAYS\" prefHeight=\"320.0\"/>\n</fx:root>"
  },
  {
    "path": "ui/src/main/resources/view/custom/editor_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.text.TextFlow?>\n<?import org.kordamp.ikonli.javafx.*?>\n<fx:root prefHeight=\"480\" prefWidth=\"640\" type=\"VBox\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <ToolBar fx:id=\"toolBar\">\n        <VBox.margin>\n            <Insets top=\"4.0\"/>\n        </VBox.margin>\n        <Button fx:id=\"undo\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2u-undo-variant\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.undo\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"redo\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2r-redo-variant\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.redo\"/>\n            </tooltip>\n        </Button>\n        <Separator orientation=\"VERTICAL\"/>\n        <Button fx:id=\"bold\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-format-bold\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.bold\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"italic\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-format-italic\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.italic\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"code\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2c-code-tags\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.code\"/>\n            </tooltip>\n        </Button>\n        <Separator orientation=\"VERTICAL\"/>\n        <Button fx:id=\"hyperlink\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2l-link-variant\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.hyperlink\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"quote\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-format-quote-open\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.quote\"/>\n            </tooltip>\n        </Button>\n        <Separator orientation=\"VERTICAL\"/>\n        <Button fx:id=\"unorderedList\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-format-list-bulleted\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.unordered-list\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"orderedList\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-format-list-numbered\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.ordered-list\"/>\n            </tooltip>\n        </Button>\n        <MenuButton fx:id=\"heading\" focusTraversable=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-format-header-pound\"/>\n            </graphic>\n            <items>\n                <MenuItem fx:id=\"header1\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-format-header-1\"/>\n                    </graphic>\n                </MenuItem>\n                <MenuItem fx:id=\"header2\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-format-header-2\"/>\n                    </graphic>\n                </MenuItem>\n                <MenuItem fx:id=\"header3\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-format-header-3\"/>\n                    </graphic>\n                </MenuItem>\n                <MenuItem fx:id=\"header4\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-format-header-4\"/>\n                    </graphic>\n                </MenuItem>\n                <MenuItem fx:id=\"header5\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-format-header-5\"/>\n                    </graphic>\n                </MenuItem>\n                <MenuItem fx:id=\"header6\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-format-header-6\"/>\n                    </graphic>\n                </MenuItem>\n            </items>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.header\"/>\n            </tooltip>\n        </MenuButton>\n        <ToggleButton fx:id=\"preview\" focusTraversable=\"false\" visible=\"false\" managed=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2e-eye\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" text=\"%editor.action.preview\"/>\n            </tooltip>\n        </ToggleButton>\n    </ToolBar>\n    <StackPane VBox.vgrow=\"ALWAYS\">\n        <TextArea fx:id=\"editor\" wrapText=\"true\"/>\n        <ScrollPane fx:id=\"previewPane\" fitToWidth=\"true\" visible=\"false\">\n            <padding>\n                <Insets top=\"10.0\" left=\"14.0\" bottom=\"10.0\" right=\"14.0\"/>\n            </padding>\n            <TextFlow fx:id=\"previewContent\" tabSize=\"4\"/>\n        </ScrollPane>\n    </StackPane>\n</fx:root>\n"
  },
  {
    "path": "ui/src/main/resources/view/custom/file_results_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.VBox?>\n<fx:root type=\"Tab\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <VBox>\n        <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\"/>\n        <TableView fx:id=\"filesTableView\" VBox.vgrow=\"ALWAYS\">\n            <placeholder>\n                <Label text=\"%search.searching\"/>\n            </placeholder>\n            <columnResizePolicy>\n                <TableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n            </columnResizePolicy>\n            <columns>\n                <TableColumn fx:id=\"tableName\" minWidth=\"320\" text=\"%name\"/>\n                <TableColumn fx:id=\"tableSize\" minWidth=\"90\" prefWidth=\"90\" maxWidth=\"90\" text=\"%size\"/>\n                <TableColumn fx:id=\"tableType\" minWidth=\"100\" prefWidth=\"100\" maxWidth=\"100\" text=\"%file-result.column.type\"/>\n                <TableColumn fx:id=\"tableHash\" minWidth=\"100\" prefWidth=\"320\" maxWidth=\"320\" text=\"%hash\"/>\n            </columns>\n        </TableView>\n    </VBox>\n</fx:root>\n"
  },
  {
    "path": "ui/src/main/resources/view/custom/gxs_group_tree_table_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.control.*?>\n<fx:root type=\"TreeTableView\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" styleClass=\"no-header\" minWidth=\"-Infinity\" prefWidth=\"200.0\">\n    <columnResizePolicy>\n        <TreeTableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n    </columnResizePolicy>\n    <columns>\n        <TreeTableColumn fx:id=\"groupNameColumn\" sortable=\"false\"/>\n        <TreeTableColumn fx:id=\"groupCountColumn\" sortable=\"false\" minWidth=\"40\" prefWidth=\"40\" maxWidth=\"45\"/>\n    </columns>\n</fx:root>"
  },
  {
    "path": "ui/src/main/resources/view/custom/image_selector_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.asyncimage.PlaceholderImageView?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.layout.StackPane?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<fx:root type=\"StackPane\" xmlns=\"http://javafx.com/javafx\"\n         xmlns:fx=\"http://javafx.com/fxml\" style=\"-fx-background-color: -color-neutral-subtle\">\n    <PlaceholderImageView fx:id=\"placeholderImageView\"/>\n    <Button fx:id=\"deleteButton\" opacity=\"0.0\" styleClass=\"flat, small, danger, button-circle, image-selector-close\" StackPane.alignment=\"TOP_RIGHT\">\n        <graphic>\n            <FontIcon iconLiteral=\"mdi2c-close-circle\"/>\n        </graphic>\n    </Button>\n    <Button fx:id=\"selectButton\" opacity=\"0.0\" styleClass=\"small\" StackPane.alignment=\"CENTER\"/>\n</fx:root>\n"
  },
  {
    "path": "ui/src/main/resources/view/custom/info_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.asyncimage.AsyncImageView?>\n<?import javafx.scene.control.ScrollPane?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<?import javafx.scene.text.TextFlow?>\n<fx:root type=\"ScrollPane\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" styleClass=\"message-pane\" fitToWidth=\"true\">\n    <VBox spacing=\"12.0\" styleClass=\"message-content\">\n        <HBox spacing=\"12.0\">\n            <AsyncImageView fx:id=\"image\" preserveRatio=\"true\"/>\n            <TextFlow fx:id=\"header\"/>\n        </HBox>\n        <TextFlow fx:id=\"body\" tabSize=\"4\"/>\n    </VBox>\n</fx:root>\n"
  },
  {
    "path": "ui/src/main/resources/view/custom/input_area_group.fxml",
    "content": "<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.InputArea?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<fx:root type=\"HBox\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <Button fx:id=\"addMedia\" styleClass=\"button-icon, flat, accent\">\n        <graphic>\n            <FontIcon iconLiteral=\"mdi2p-paperclip\"/>\n        </graphic>\n        <tooltip>\n            <Tooltip maxWidth=\"300\" wrapText=\"true\" showDuration=\"1m\" text=\"%messaging.send.file\"/>\n        </tooltip>\n    </Button>\n    <InputArea fx:id=\"inputArea\" promptText=\"%messaging.prompt\" VBox.vgrow=\"NEVER\" HBox.hgrow=\"ALWAYS\"/>\n    <Button fx:id=\"addSticker\" styleClass=\"button-icon, flat, accent\">\n        <graphic>\n            <FontIcon iconLiteral=\"mdi2s-sticker-emoji\"/>\n        </graphic>\n        <tooltip>\n            <Tooltip maxWidth=\"300\" wrapText=\"true\" showDuration=\"1m\" text=\"%messaging.send-sticker\"/>\n        </tooltip>\n    </Button>\n    <Button fx:id=\"callButton\" styleClass=\"button-icon, flat, accent\" visible=\"false\" managed=\"false\">\n        <graphic>\n            <FontIcon iconLiteral=\"mdi2p-phone\"/>\n        </graphic>\n        <tooltip>\n            <Tooltip maxWidth=\"300\" wrapText=\"true\" showDuration=\"1m\" text=\"%messaging.action.call\"/>\n        </tooltip>\n    </Button>\n</fx:root>"
  },
  {
    "path": "ui/src/main/resources/view/custom/sticker_view.fxml",
    "content": "<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.control.TabPane?>\n<?import javafx.scene.layout.VBox?>\n<fx:root type=\"VBox\" minWidth=\"600.0\" minHeight=\"400.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" styleClass=\"base-spacing, popup-window\">\n    <TabPane fx:id=\"tabPane\" tabClosingPolicy=\"UNAVAILABLE\" side=\"BOTTOM\" VBox.vgrow=\"ALWAYS\" maxWidth=\"600\"/>\n</fx:root>"
  },
  {
    "path": "ui/src/main/resources/view/custom/typing_notification_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.WaveDotsView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.Label?>\n<?import javafx.scene.control.ProgressIndicator?>\n<?import javafx.scene.layout.HBox?>\n<fx:root type=\"HBox\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <ProgressIndicator fx:id=\"progressIndicator\" prefWidth=\"16.0\" prefHeight=\"16.0\" managed=\"false\">\n        <HBox.margin>\n            <Insets right=\"4.0\"/>\n        </HBox.margin>\n    </ProgressIndicator>\n    <Label fx:id=\"text\"/>\n    <WaveDotsView alignment=\"BOTTOM_LEFT\" fx:id=\"waveDotsView\" visible=\"false\">\n        <HBox.margin>\n            <Insets left=\"1.0\" bottom=\"10.0\"/><!-- not sure why I need the bottom margin -->\n        </HBox.margin>\n    </WaveDotsView>\n</fx:root>\n"
  },
  {
    "path": "ui/src/main/resources/view/custom/wave_dots_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.shape.Circle?>\n<fx:root type=\"HBox\" minHeight=\"3.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <Circle fx:id=\"circle1\" radius=\"1.0\" styleClass=\"chat-dot-wait\">\n        <HBox.margin>\n            <Insets left=\"1.0\" right=\"1.0\"/>\n        </HBox.margin>\n    </Circle>\n    <Circle fx:id=\"circle2\" radius=\"1.0\" styleClass=\"chat-dot-wait\">\n        <HBox.margin>\n            <Insets left=\"1.0\" right=\"1.0\"/>\n        </HBox.margin>\n    </Circle>\n    <Circle fx:id=\"circle3\" radius=\"1.0\" styleClass=\"chat-dot-wait\">\n        <HBox.margin>\n            <Insets left=\"1.0\" right=\"1.0\"/>\n        </HBox.margin>\n    </Circle>\n</fx:root>\n"
  },
  {
    "path": "ui/src/main/resources/view/debug/debug_requester_view.fxml",
    "content": "<!--\n  ~ Copyright (c) 2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.collections.FXCollections?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import java.lang.String?>\n<VBox alignment=\"CENTER\" styleClass=\"base-spacing\" prefWidth=\"240\" prefHeight=\"160\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.debug.DebugRequesterWindowController\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"Name\"/>\n        <TextField promptText=\"Enter name\" GridPane.columnIndex=\"1\"/>\n        <Label text=\"Encryption\" GridPane.rowIndex=\"1\"/>\n        <ComboBox fx:id=\"comboBox\" GridPane.rowIndex=\"1\" GridPane.columnIndex=\"1\" maxWidth=\"Infinity\">\n            <items>\n                <FXCollections fx:factory=\"observableArrayList\">\n                    <String fx:value=\"Cosmic Dust\"/>\n                </FXCollections>\n            </items>\n        </ComboBox>\n    </GridPane>\n    <Region VBox.vgrow=\"ALWAYS\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button defaultButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%ok\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/default.css",
    "content": "/*\n * Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n.base-spacing {\n    -fx-padding: 12px;\n}\n\n.ikonli-font-icon {\n    -fx-icon-size: 20px; /* AtlantaFX defaults to 18px but somehow my text fields are 2 pixels bigger so it needs 20 to be able to group them in input groups */\n}\n\n.chat-list-pane {\n    -fx-background-color: -color-border-default;\n    -fx-padding: 1px;\n}\n\n.chat-list {\n    -fx-background-color: -color-bg-default;\n}\n\n.chat-list .list-cell {\n    -fx-padding: 2px 8px 2px 8px;\n}\n\n/*noinspection CssInvalidPseudoSelector,CssUnusedSymbol*/\n.chat-list .list-cell:passive > .label {\n    -fx-text-fill: -color-fg-subtle;\n}\n\n/*noinspection CssInvalidPseudoSelector,CssUnusedSymbol*/\n.chat-list .list-cell:passive > Text {\n    -fx-fill: -color-fg-subtle;\n}\n\n/*noinspection CssInvalidPseudoSelector,CssUnusedSymbol*/\n.chat-list .list-cell:quoted > Text {\n    -fx-fill: -color-accent-fg;\n}\n\n.chat-list .list-cell .time {\n    -fx-padding: 0px 0.25em 0px 0px;\n    -fx-text-fill: -color-fg-muted;\n}\n\n.chat-list .list-cell .action {\n    -fx-padding: 0px 0.25em 0px 0px;\n}\n\n.chat-user-list {\n    -fx-border-width: 0 1 1 1;\n}\n\n.foo-view {\n    -fx-table-cell-border-color: transparent;\n}\n\n.chat-dot-wait {\n    -fx-fill: -color-fg-default;\n}\n\n.forum-header-title {\n    -fx-text-fill: -color-fg-subtle;\n}\n\n/* Remove horizontal lines between entries in TreeTableView */\n.no-horizontal-lines .tree-table-row-cell {\n    -fx-background-insets: 0;\n    -fx-padding: 0.0em;\n}\n\n.alert-textarea {\n    -fx-background-color: transparent;\n    -fx-background-insets: 0px;\n}\n\n.alert-textarea .content {\n    -fx-padding: 0 0 0 0;\n}\n\n.message-header {\n    -fx-background-color: -color-bg-subtle;\n    -fx-border-color: -color-border-default;\n    -fx-padding: 8px;\n}\n\n.message-pane {\n    -fx-border-color: -color-border-default;\n    -fx-border-width: 0 1 1 1;\n}\n\n/* workaround for disappearing borders */\n.message-pane .viewport {\n    -fx-border-color: -color-border-default;\n    -fx-border-width: 0 0 1 0;\n}\n\n.message-content {\n    -fx-padding: 8px;\n}\n\n.hamburger-menu-bar {\n    -fx-background-color: transparent;\n}\n\n/* led */\n.led-control {\n    -color: black;\n}\n\n.led-status-ok {\n    -color: #adff2f;\n}\n\n.led-status-warning {\n    -color: #ffa500;\n}\n\n.led-status-error {\n    -color: #ff3333;\n}\n\n.led-control .frame {\n    -fx-background-color: linear-gradient(from 14% 14% to 84% 84%,\n    rgba(20, 20, 20, 0.64706) 0%,\n    rgba(20, 20, 20, 0.64706) 15%,\n    rgba(41, 41, 41, 0.64706) 26%,\n    rgba(200, 200, 200, 0.40631) 85%,\n    rgba(200, 200, 200, 0.3451) 100%);\n    -fx-background-radius: 1024px;\n}\n\n/*noinspection CssInvalidFunction*/\n.led-control .main {\n    -fx-background-color: linear-gradient(from 15% 15% to 83% 83%,\n    derive(-color, -80%) 0%,\n    derive(-color, -87%) 49%,\n    derive(-color, -80%) 100%);\n    -fx-background-radius: 1024px;\n}\n\n/*noinspection CssInvalidFunction,CssInvalidPseudoSelector*/\n.led-control:on .main {\n    -fx-background-color: linear-gradient(from 15% 15% to 83% 83%,\n    derive(-color, -23%) 0%,\n    derive(-color, -50%) 49%,\n    -color 100%);\n}\n\n.led-control .highlight {\n    -fx-background-color: radial-gradient(center 15% 15%, radius 50%, white 0%, transparent 100%);\n    -fx-background-radius: 1024;\n}\n\n#addFriendButton .ikonli-font-icon {\n    -fx-icon-color: blue;\n    -fx-icon-size: 24px;\n}\n\n#webHelpButton .ikonli-font-icon {\n    -fx-icon-color: green;\n    -fx-icon-size: 24px;\n}\n\n.imageview-avatar {\n    -fx-background-color: -color-bg-subtle;\n    -fx-border-color: -color-border-default;\n    -fx-padding: 1px;\n}\n\n.color-00 {\n    -fx-text-fill: #CC0000;\n}\n\n.color-01 {\n    -fx-text-fill: #006CAD;\n}\n\n.color-02 {\n    -fx-text-fill: #4D9900;\n}\n\n.color-03 {\n    -fx-text-fill: #6600CC;\n}\n\n.color-04 {\n    -fx-text-fill: #A67D00;\n}\n\n.color-05 {\n    -fx-text-fill: #009927;\n}\n\n.color-06 {\n    -fx-text-fill: #0030C0;\n}\n\n.color-07 {\n    -fx-text-fill: #CC009A;\n}\n\n.color-08 {\n    -fx-text-fill: #B94600;\n}\n\n.color-09 {\n    -fx-text-fill: #869900;\n}\n\n.color-10 {\n    -fx-text-fill: #149900;\n}\n\n.color-11 {\n    -fx-text-fill: #009960;\n}\n\n.color-12 {\n    -fx-text-fill: #006CAD;\n}\n\n.color-13 {\n    -fx-text-fill: #0099CC;\n}\n\n.color-14 {\n    -fx-text-fill: #B300CC;\n}\n\n.color-15 {\n    -fx-text-fill: #CC004D;\n}\n\n.chart-line-symbol {\n    -fx-background-insets: 0, 5; /* remove the white dot in the legend */\n}\n\n.group-emphasis:hover {\n    -fx-background-color: -color-neutral-emphasis;\n}\n\n.group-frame {\n    -fx-border-color: -color-neutral-emphasis;\n}\n\n/* PromptText stays visible during focus */\n.text-input, .text-input:focused {\n    -fx-prompt-text-fill: -color-fg-subtle;\n}\n\n.popup-window {\n    -fx-background-color: -color-bg-default;\n    -fx-border-color: -color-border-default;\n}\n\n.tool-bar {\n    -fx-padding: 2 2 2 2;\n    -fx-spacing: 2;\n}\n\n.tool-bar-flat {\n    -fx-padding: 2 2 2 2;\n    -fx-spacing: 2;\n    -fx-background-color: -color-bg-default;\n}\n\n.tool-bar-flat > .container > .button {\n    -color-button-bg: -color-bg-default;\n}\n\n.tool-bar-flat > .container > .menu-button {\n    -color-button-bg: -color-bg-default;\n}\n\n.tool-bar-flat > .container > .split-menu-button {\n    -color-button-bg: -color-bg-default;\n}\n\n.tool-bar-flat > .container > .toggle-button {\n    -color-button-bg: -color-bg-default;\n}\n\n.sticker-image:hover {\n    -fx-effect: dropshadow(three-pass-box, -color-accent-emphasis, 8.0, 0.5, 0, 0);\n    -fx-scale-x: 1.05;\n    -fx-scale-y: 1.05;\n    -fx-cursor: hand;\n}\n\n.tab-bold {\n    -fx-font-weight: bold;\n}\n\n.board-cell {\n    -fx-padding: 4px 4px 14px 4px;\n    -fx-border-color: -color-neutral-emphasis;\n    -fx-border-width: 0px 0px 1px 0px;\n}\n\n.board-cell > #titleFlow {\n    -fx-padding: 0px 0px 4px 0px;\n}\n\n.channel-cell {\n    -fx-padding: 4px 4px 14px 4px;\n    -fx-border-color: -color-neutral-emphasis;\n    -fx-border-width: 0px 0px 1px 0px;\n}\n\n.channel-cell:selected {\n    -fx-background-color: -color-accent-emphasis;\n}\n\n.channel-cell > * > #titleLabel {\n    -fx-font-size: 1.25em;\n}\n\n.channel-cell:unread > * > #titleLabel {\n    -fx-font-weight: bold;\n}\n\n.panel-border {\n    -fx-border-color: -color-border-default;\n    -fx-border-width: 1px;\n}\n\n.image-selector-close {\n    -fx-padding: 1px;\n}\n\n.small-toggle {\n    -fx-padding: 2px 4px 2px 4px;\n    -fx-font-size: 12px;\n}\n\n.uri-preview {\n    -fx-background-color: -color-neutral-subtle;\n    -fx-background-radius: 4px;\n    -fx-border-radius: 4px;\n    -fx-border-color: -color-neutral-emphasis;\n    -fx-border-width: 1px;\n    -fx-padding: 4px;\n    -fx-cursor: hand;\n}"
  },
  {
    "path": "ui/src/main/resources/view/file/add_download.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefHeight=\"240.0\" prefWidth=\"400.0\" minHeight=\"-Infinity\" minWidth=\"-Infinity\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.file.FileAddDownloadViewWindowController\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%name\"/>\n        <ReadOnlyTextField fx:id=\"name\" GridPane.columnIndex=\"1\"/>\n        <Label text=\"%size\" GridPane.rowIndex=\"1\"/>\n        <ReadOnlyTextField fx:id=\"size\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\"/>\n        <Label text=\"%hash\" GridPane.rowIndex=\"2\"/>\n        <ReadOnlyTextField fx:id=\"hash\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\"/>\n    </GridPane>\n    <Region VBox.vgrow=\"ALWAYS\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"downloadButton\" defaultButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%download\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/file/download.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.VBox?>\n<VBox spacing=\"4.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.file.FileDownloadViewController\">\n    <padding>\n        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n    </padding>\n    <TableView fx:id=\"downloadTableView\" VBox.vgrow=\"ALWAYS\">\n        <placeholder>\n            <Label text=\"%download-view.list.none\"/>\n        </placeholder>\n        <columnResizePolicy>\n            <TableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n        </columnResizePolicy>\n        <columns>\n            <TableColumn fx:id=\"tableName\" minWidth=\"320\" text=\"%name\"/>\n            <TableColumn fx:id=\"tableState\" minWidth=\"120\" prefWidth=\"120\" maxWidth=\"120\" text=\"%download-view.list.state\"/>\n            <TableColumn fx:id=\"tableProgress\" minWidth=\"100\" prefWidth=\"160\" maxWidth=\"160\" text=\"%download-view.list.progress\"/>\n            <TableColumn fx:id=\"tableTotalSize\" minWidth=\"100\" prefWidth=\"100\" maxWidth=\"100\" text=\"%download-view.list.total-size\"/>\n            <TableColumn fx:id=\"tableHash\" minWidth=\"100\" prefWidth=\"320.0\" maxWidth=\"320\" text=\"%hash\"/>\n        </columns>\n    </TableView>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/file/main.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<!--suppress JavaFxUnresolvedFxIdReference -->\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.file.FileMainController\">\n    <AnchorPane VBox.vgrow=\"ALWAYS\">\n        <TabPane fx:id=\"tabPane\" side=\"LEFT\" tabClosingPolicy=\"UNAVAILABLE\" AnchorPane.bottomAnchor=\"0\" AnchorPane.leftAnchor=\"0\" AnchorPane.rightAnchor=\"0\" AnchorPane.topAnchor=\"0\">\n            <Tab fx:id=\"search\" text=\"%search.main.search\">\n                <graphic>\n                    <FontIcon iconLiteral=\"mdi2m-magnify\"/>\n                </graphic>\n                <fx:include fx:id=\"fileSearchView\" source=\"search.fxml\"/>\n            </Tab>\n            <Tab fx:id=\"downloads\" text=\"%search.main.downloads\">\n                <graphic>\n                    <FontIcon iconLiteral=\"mdi2d-download\"/>\n                </graphic>\n                <fx:include fx:id=\"fileDownloadView\" source=\"download.fxml\"/>\n            </Tab>\n            <Tab fx:id=\"uploads\" text=\"%search.main.uploads\">\n                <graphic>\n                    <FontIcon iconLiteral=\"mdi2u-upload\"/>\n                </graphic>\n                <fx:include fx:id=\"fileUploadView\" source=\"upload.fxml\"/>\n            </Tab>\n            <Tab fx:id=\"trends\" text=\"%search.main.trends\">\n                <graphic>\n                    <FontIcon iconLiteral=\"mdi2t-trending-up\"/>\n                </graphic>\n                <fx:include fx:id=\"fileTrendView\" source=\"trend.fxml\"/>\n            </Tab>\n        </TabPane>\n    </AnchorPane>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/file/search.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.TabPane?>\n<?import javafx.scene.control.TextField?>\n<?import javafx.scene.control.Tooltip?>\n<?import javafx.scene.layout.VBox?>\n<VBox spacing=\"4.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.file.FileSearchViewController\">\n    <padding>\n        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n    </padding>\n    <TextField fx:id=\"search\" promptText=\"%search.input.prompt\">\n        <tooltip>\n            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%search.input.search.tip\"/>\n        </tooltip>\n    </TextField>\n    <TabPane fx:id=\"resultTabPane\" tabClosingPolicy=\"ALL_TABS\" styleClass=\"floating\" VBox.vgrow=\"ALWAYS\"/>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/file/share.fxml",
    "content": "<!--\n  ~ Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox minWidth=\"800.0\" minHeight=\"600.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.share.ShareWindowController\" styleClass=\"base-spacing\">\n    <TableView fx:id=\"shareTableView\" VBox.vgrow=\"ALWAYS\" editable=\"true\">\n        <columns>\n            <TableColumn fx:id=\"tableDirectory\" prefWidth=\"350.0\" text=\"%share.list.directory\"/>\n            <TableColumn fx:id=\"tableName\" prefWidth=\"130.0\" text=\"%share.list.visible-name\"/>\n            <TableColumn fx:id=\"tableSearchable\" prefWidth=\"100.0\" text=\"%share.list.searchable\"/>\n            <TableColumn fx:id=\"tableBrowsable\" prefWidth=\"140.0\" text=\"%share.list.browsable\"/>\n        </columns>\n    </TableView>\n    <HBox>\n        <Button fx:id=\"addButton\" text=\"%add\">\n            <HBox.margin>\n                <Insets top=\"12.0\"/>\n            </HBox.margin>\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2f-folder-plus\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%share.create\"/>\n            </tooltip>\n        </Button>\n        <Spacer HBox.hgrow=\"ALWAYS\"/>\n        <Button fx:id=\"applyButton\" defaultButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%share.apply\">\n            <HBox.margin>\n                <Insets right=\"4.0\" top=\"12.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\" top=\"12.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/file/trend.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.VBox?>\n<VBox spacing=\"4.0\" xmlns=\"http://javafx.com/javafx\"\n      xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.file.FileTrendViewController\">\n    <padding>\n        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n    </padding>\n    <TableView fx:id=\"trendTableView\" VBox.vgrow=\"ALWAYS\">\n        <columnResizePolicy>\n            <TableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n        </columnResizePolicy>\n        <placeholder>\n            <Label text=\"%trends.none\"/>\n        </placeholder>\n        <columns>\n            <TableColumn fx:id=\"tableTerms\" minWidth=\"100.0\" text=\"%trends.list.terms\" sortable=\"false\"/>\n            <TableColumn fx:id=\"tableFrom\" minWidth=\"120.0\" prefWidth=\"120.0\" maxWidth=\"140.0\" text=\"%trends.list.from\" sortable=\"false\"/>\n            <TableColumn fx:id=\"tableTime\" minWidth=\"100.0\" prefWidth=\"140.0\" maxWidth=\"140.0\" text=\"%trends.list.time\" sortable=\"false\"/>\n        </columns>\n    </TableView>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/file/upload.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.VBox?>\n<VBox spacing=\"4.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.file.FileUploadViewController\">\n    <padding>\n        <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n    </padding>\n    <TableView fx:id=\"uploadTableView\" VBox.vgrow=\"ALWAYS\">\n        <placeholder>\n            <Label text=\"%upload-view.none\"/>\n        </placeholder>\n        <columnResizePolicy>\n            <TableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n        </columnResizePolicy>\n        <columns>\n            <TableColumn fx:id=\"tableName\" minWidth=\"320\" text=\"%name\"/>\n            <TableColumn fx:id=\"tableTotalSize\" minWidth=\"100\" prefWidth=\"100\" maxWidth=\"100\" text=\"%download-view.list.total-size\"/>\n            <TableColumn fx:id=\"tableHash\" minWidth=\"100\" prefWidth=\"320.0\" text=\"%hash\"/>\n        </columns>\n    </TableView>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/forum/forum_editor_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><!--\n  ~ Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.EditorView?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefWidth=\"640\" prefHeight=\"480\" minWidth=\"320.0\" minHeight=\"256.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%forum.editor.name\"/>\n        <ReadOnlyTextField fx:id=\"forumName\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.editor.name.prompt\"/>\n            </tooltip>\n        </ReadOnlyTextField>\n        <Label text=\"%subject\" GridPane.rowIndex=\"1\"/>\n        <TextField fx:id=\"title\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.editor.thread.description\"/>\n            </tooltip>\n        </TextField>\n    </GridPane>\n    <EditorView fx:id=\"editorView\" VBox.vgrow=\"SOMETIMES\"/>\n    <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\" managed=\"false\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"send\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%send\">\n            <HBox.margin>\n                <Insets top=\"8.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/forum/forum_group_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefHeight=\"180.0\" prefWidth=\"400.0\" minHeight=\"-Infinity\" minWidth=\"-Infinity\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.forum.ForumGroupWindowController\">\n    <GridPane hgap=\"8\" vgap=\"8\">\n        <columnConstraints>\n            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n        </columnConstraints>\n        <rowConstraints>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n            <RowConstraints vgrow=\"ALWAYS\"/>\n        </rowConstraints>\n        <Label text=\"%name\"/>\n        <TextField fx:id=\"forumName\" promptText=\"%forum.create.name.prompt\" GridPane.columnIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.create.name.tip\"/>\n            </tooltip>\n        </TextField>\n        <Label text=\"%description\" GridPane.rowIndex=\"1\"/>\n        <TextField fx:id=\"forumDescription\" promptText=\"%forum.create.description.prompt\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.create.description.tip\"/>\n            </tooltip>\n        </TextField>\n    </GridPane>\n    <Region VBox.vgrow=\"ALWAYS\"/>\n    <ProgressBar fx:id=\"progressBar\" styleClass=\"small\" prefWidth=\"Infinity\" minHeight=\"4.0\" managed=\"false\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"createOrUpdateButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%create\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/forum/forum_view.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.controller.common.GxsGroupTreeTableView?>\n<?import io.xeres.ui.custom.ProgressPane?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.text.TextFlow?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox alignment=\"CENTER\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.forum.ForumViewController\">\n    <SplitPane fx:id=\"splitPaneVertical\" dividerPositions=\"0.1\" VBox.vgrow=\"ALWAYS\">\n        <VBox SplitPane.resizableWithParent=\"false\" VBox.vgrow=\"ALWAYS\">\n            <HBox alignment=\"CENTER_RIGHT\" minHeight=\"-Infinity\" VBox.vgrow=\"NEVER\">\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"createForum\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-folder-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.view.create.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <GxsGroupTreeTableView fx:id=\"forumTree\" VBox.vgrow=\"ALWAYS\" styleClass=\"no-horizontal-lines\"/>\n        </VBox>\n        <VBox minWidth=\"200.0\" VBox.vgrow=\"ALWAYS\">\n            <HBox>\n                <VBox.margin>\n                    <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n                </VBox.margin>\n                <Button fx:id=\"newThread\" disable=\"true\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-pencil-plus\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.view.new-message.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n            <SplitPane fx:id=\"splitPaneHorizontal\" dividerPositions=\"0.3\" orientation=\"VERTICAL\" VBox.vgrow=\"ALWAYS\">\n                <StackPane styleClass=\"panel-border\">\n                    <ProgressPane fx:id=\"forumMessagesProgress\">\n                        <TreeTableView fx:id=\"forumMessagesTreeTableView\" minHeight=\"50.0\" prefHeight=\"150.0\" styleClass=\"dense\">\n                            <placeholder>\n                                <Label/>\n                            </placeholder>\n                            <columnResizePolicy>\n                                <TreeTableView fx:constant=\"CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS\"/>\n                            </columnResizePolicy>\n                            <columns>\n                                <TreeTableColumn fx:id=\"treeTableSubject\" prefWidth=\"220\" text=\"%subject\"/>\n                                <TreeTableColumn fx:id=\"treeTableAuthor\" minWidth=\"40\" prefWidth=\"180\" text=\"%forum.view.header.author\"/>\n                                <TreeTableColumn fx:id=\"treeTableDate\" minWidth=\"40\" prefWidth=\"140\" maxWidth=\"140\" text=\"%forum.view.header.date\"/>\n                            </columns>\n                        </TreeTableView>\n                    </ProgressPane>\n                </StackPane>\n                <VBox>\n                    <GridPane fx:id=\"messageHeader\" styleClass=\"message-header\" visible=\"false\" managed=\"false\" hgap=\"8\" vgap=\"4\">\n                        <columnConstraints>\n                            <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                            <ColumnConstraints hgrow=\"ALWAYS\" minWidth=\"10.0\"/>\n                            <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\"/>\n                        </columnConstraints>\n                        <rowConstraints>\n                            <RowConstraints vgrow=\"ALWAYS\"/>\n                            <RowConstraints vgrow=\"ALWAYS\"/>\n                        </rowConstraints>\n                        <Label text=\"%forum.view.from\" styleClass=\"forum-header-title\"/>\n                        <Label fx:id=\"messageAuthor\" GridPane.columnIndex=\"1\"/>\n                        <Label fx:id=\"messageDate\" GridPane.columnIndex=\"2\"/>\n                        <Label text=\"%forum.view.subject\" GridPane.rowIndex=\"1\" styleClass=\"forum-header-title\"/>\n                        <Label fx:id=\"messageSubject\" GridPane.rowIndex=\"1\" GridPane.columnIndex=\"1\" GridPane.columnSpan=\"2\"/>\n                        <ChoiceBox fx:id=\"versionChoiceBox\" prefWidth=\"160\" visible=\"false\" GridPane.columnIndex=\"2\" GridPane.RowIndex=\"1\">\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%forum.view.history\"/>\n                            </tooltip>\n                        </ChoiceBox>\n                    </GridPane>\n                    <ScrollPane fx:id=\"messagePane\" styleClass=\"message-pane\" fitToWidth=\"true\" VBox.vgrow=\"ALWAYS\">\n                        <TextFlow fx:id=\"messageContent\" tabSize=\"4\" styleClass=\"message-content\"/>\n                    </ScrollPane>\n                </VBox>\n            </SplitPane>\n        </VBox>\n    </SplitPane>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/help/help.fxml",
    "content": "<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.EditorView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox minWidth=\"640\" minHeight=\"480\" prefWidth=\"800\" prefHeight=\"600\" alignment=\"CENTER\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.help.HelpWindowController\">\n    <ToolBar>\n        <VBox.margin>\n            <Insets top=\"4.0\"/>\n        </VBox.margin>\n        <Button fx:id=\"back\" disable=\"true\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2a-arrow-left-bold\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%help.back\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"forward\" disable=\"true\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2a-arrow-right-bold\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%help.forward\"/>\n            </tooltip>\n        </Button>\n        <Button fx:id=\"home\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2h-home\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%help.home\"/>\n            </tooltip>\n        </Button>\n    </ToolBar>\n    <HBox VBox.vgrow=\"SOMETIMES\">\n        <ListView fx:id=\"indexList\"/>\n        <EditorView fx:id=\"editorView\" previewOnly=\"true\" HBox.hgrow=\"SOMETIMES\"/>\n    </HBox>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/id/rsid_add.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.image.ImageView?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox prefWidth=\"560.0\" prefHeight=\"540.0\" minWidth=\"400.0\" minHeight=\"500.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.id.AddRsIdWindowController\">\n    <padding>\n        <Insets left=\"12\" right=\"12\" bottom=\"12\"/>\n    </padding>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"scanQrCode\" styleClass=\"flat\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2q-qrcode-scan\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.scan\"/>\n            </tooltip>\n        </Button>\n    </HBox>\n    <TextArea fx:id=\"rsIdTextArea\" promptText=\"%rs-id.add.textarea.prompt\" VBox.vgrow=\"SOMETIMES\" styleClass=\"fixed-font\" wrapText=\"true\">\n        <tooltip>\n            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.textarea.tip\"/>\n        </tooltip>\n    </TextArea>\n    <Label fx:id=\"status\"/>\n    <TitledPane fx:id=\"titledPane\" expanded=\"false\" text=\"%rs-id.add.details\" VBox.vgrow=\"ALWAYS\">\n        <GridPane hgap=\"8\" vgap=\"8\">\n            <columnConstraints>\n                <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n            </columnConstraints>\n            <rowConstraints>\n                <RowConstraints vgrow=\"ALWAYS\"/>\n                <RowConstraints vgrow=\"ALWAYS\"/>\n                <RowConstraints vgrow=\"ALWAYS\"/>\n                <RowConstraints vgrow=\"ALWAYS\"/>\n                <RowConstraints vgrow=\"ALWAYS\"/>\n                <RowConstraints vgrow=\"ALWAYS\"/>\n            </rowConstraints>\n            <Label text=\"%name\"/>\n            <ReadOnlyTextField fx:id=\"certName\" GridPane.columnIndex=\"1\">\n                <tooltip>\n                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.name.tip\"/>\n                </tooltip>\n            </ReadOnlyTextField>\n            <Label text=\"%rs-id.add.profile\" GridPane.rowIndex=\"1\"/>\n            <ReadOnlyTextField fx:id=\"certId\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n                <tooltip>\n                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.profile.tip\"/>\n                </tooltip>\n            </ReadOnlyTextField>\n            <Label text=\"%rs-id.add.fingerprint\" GridPane.rowIndex=\"2\"/>\n            <ReadOnlyTextField fx:id=\"certFingerprint\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\">\n                <tooltip>\n                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.fingerprint.tip\"/>\n                </tooltip>\n            </ReadOnlyTextField>\n            <Label text=\"%rs-id.add.location\" GridPane.rowIndex=\"3\"/>\n            <ReadOnlyTextField fx:id=\"certLocId\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"3\">\n                <tooltip>\n                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.location.tip\"/>\n                </tooltip>\n            </ReadOnlyTextField>\n            <Label text=\"%rs-id.add.addresses\" GridPane.rowIndex=\"4\"/>\n            <HBox GridPane.columnIndex=\"1\" GridPane.rowIndex=\"4\">\n                <ComboBox fx:id=\"certIps\" minWidth=\"150.0\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.addresses.tip\"/>\n                    </tooltip>\n                </ComboBox>\n                <ImageView fx:id=\"imageFlag\">\n                    <HBox.margin>\n                        <Insets left=\"8.0\"/>\n                    </HBox.margin>\n                </ImageView>\n            </HBox>\n            <Label text=\"%trust\" GridPane.rowIndex=\"5\"/>\n            <ChoiceBox fx:id=\"trust\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"5\" minWidth=\"150.0\">\n                <tooltip>\n                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%rs-id.add.trust.tip\"/>\n                </tooltip>\n            </ChoiceBox>\n        </GridPane>\n    </TitledPane>\n    <HBox alignment=\"TOP_RIGHT\">\n        <VBox.margin>\n            <Insets top=\"8.0\"/>\n        </VBox.margin>\n        <Button fx:id=\"addButton\" defaultButton=\"true\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%add\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancelButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/linux.css",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n.fixed-font {\n    -fx-font-family: \"monospace\";\n}\n"
  },
  {
    "path": "ui/src/main/resources/view/mac.css",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n.fixed-font {\n    -fx-font-family: \"monospace\";\n}\n"
  },
  {
    "path": "ui/src/main/resources/view/main.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.led.LedControl?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.image.Image?>\n<?import javafx.scene.image.ImageView?>\n<?import javafx.scene.input.KeyCodeCombination?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.*?>\n<StackPane fx:id=\"stackPane\" minWidth=\"640.0\" minHeight=\"460.0\" prefWidth=\"780.0\" prefHeight=\"500.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.MainWindowController\">\n    <VBox>\n        <AnchorPane VBox.vgrow=\"ALWAYS\">\n            <TabPane fx:id=\"tabPane\" maxHeight=\"1.7976931348623157E308\" maxWidth=\"1.7976931348623157E308\" prefHeight=\"400.0\" prefWidth=\"600.0\" tabClosingPolicy=\"UNAVAILABLE\" AnchorPane.bottomAnchor=\"0\" AnchorPane.leftAnchor=\"0\" AnchorPane.rightAnchor=\"0\" AnchorPane.topAnchor=\"0\">\n                <Tab fx:id=\"homeTab\" text=\"%main.home\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2h-home\"/>\n                    </graphic>\n                    <VBox alignment=\"CENTER\">\n                        <HBox alignment=\"CENTER\">\n                            <opaqueInsets>\n                                <Insets/>\n                            </opaqueInsets>\n                            <VBox.margin>\n                                <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"16.0\"/>\n                            </VBox.margin>\n                            <ImageView fx:id=\"logo\" fitHeight=\"64.0\" fitWidth=\"64.0\" pickOnBounds=\"true\" preserveRatio=\"true\">\n                                <Image url=\"@../image/icon.png\"/>\n                                <HBox.margin>\n                                    <Insets right=\"32.0\"/>\n                                </HBox.margin>\n                            </ImageView>\n                            <Label fx:id=\"titleLabel\" text=\"Xeres\" styleClass=\"title-1\"/>\n                        </HBox>\n                        <Label fx:id=\"slogan\" text=\"%main.home.slogan\" styleClass=\"text-caption\">\n                            <VBox.margin>\n                                <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"8.0\"/>\n                            </VBox.margin>\n                        </Label>\n                        <Label fx:id=\"shareId\" text=\"%main.home.share-id\">\n                            <VBox.margin>\n                                <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"16.0\"/>\n                            </VBox.margin>\n                        </Label>\n                        <HBox alignment=\"CENTER\" maxWidth=\"1.7976931348623157E308\">\n                            <ReadOnlyTextField fx:id=\"shortId\" prefWidth=\"540.0\" styleClass=\"left-pill\"/>\n                            <Button fx:id=\"copyShortIdButton\" styleClass=\"right-pill, button-icon\">\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2c-clipboard\"/>\n                                </graphic>\n                                <tooltip>\n                                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%main.home.copy-id.tip\"/>\n                                </tooltip>\n                            </Button>\n                            <Button fx:id=\"showQrCodeButton\">\n                                <HBox.margin>\n                                    <Insets left=\"8.0\"/>\n                                </HBox.margin>\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2q-qrcode\"/>\n                                </graphic>\n                                <tooltip>\n                                    <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%main.home.qrcode.tip\"/>\n                                </tooltip>\n                            </Button>\n                        </HBox>\n                        <Label text=\"%main.home.received-id\">\n                            <VBox.margin>\n                                <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"16.0\"/>\n                            </VBox.margin>\n                        </Label>\n                        <Button fx:id=\"addFriendButton\" mnemonicParsing=\"false\" text=\"%main.home.add-peer\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2a-account-plus\"/>\n                            </graphic>\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%main.home.add-peer.tip\"/>\n                            </tooltip>\n                        </Button>\n                        <Label text=\"%main.home.need-help\">\n                            <VBox.margin>\n                                <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"16.0\"/>\n                            </VBox.margin>\n                        </Label>\n                        <Button fx:id=\"webHelpButton\" mnemonicParsing=\"false\" text=\"%main.home.online-help\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2e-earth\"/>\n                            </graphic>\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%main.home.online-help.tip\"/>\n                            </tooltip>\n                        </Button>\n                        <Region prefHeight=\"200.0\" prefWidth=\"200.0\" VBox.vgrow=\"ALWAYS\"/>\n                    </VBox>\n                </Tab>\n                <Tab fx:id=\"contactTab\" text=\"%main.contacts\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2a-account-group\"/>\n                    </graphic>\n                    <fx:include fx:id=\"contactsView\" source=\"contact/contact_view.fxml\"/>\n                </Tab>\n                <Tab fx:id=\"chatTab\" text=\"%main.chats\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2c-chat\"/>\n                    </graphic>\n                    <!--suppress Annotator -->\n                    <fx:include fx:id=\"chatView\" source=\"chat/chat_view.fxml\"/>\n                </Tab>\n                <Tab fx:id=\"forumTab\" text=\"%main.forums\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2b-bullhorn\"/>\n                    </graphic>\n                    <!--suppress Annotator -->\n                    <fx:include fx:id=\"forumsView\" source=\"forum/forum_view.fxml\"/>\n                </Tab>\n                <Tab fx:id=\"channelTab\" text=\"%main.channels\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-play-box\"/>\n                    </graphic>\n                    <!--suppress Annotator -->\n                    <fx:include fx:id=\"channelsView\" source=\"channel/channel_view.fxml\"/>\n                </Tab>\n                <Tab fx:id=\"boardTab\" text=\"%main.boards\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2v-view-dashboard-outline\"/>\n                    </graphic>\n                    <!--suppress Annotator -->\n                    <fx:include fx:id=\"boardsView\" source=\"board/board_view.fxml\"/>\n                </Tab>\n                <Tab fx:id=\"fileTab\" text=\"%main.files\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-folder-network\"/>\n                    </graphic>\n                    <fx:include fx:id=\"fileMain\" source=\"file/main.fxml\"/>\n                </Tab>\n            </TabPane>\n            <MenuBar AnchorPane.topAnchor=\"0\" AnchorPane.rightAnchor=\"0\" styleClass=\"hamburger-menu-bar\">\n                <Menu>\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2m-menu\"/>\n                    </graphic>\n                    <MenuItem fx:id=\"addPeer\" text=\"%main.menu.add-peer\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2a-account-plus\"/>\n                        </graphic>\n                        <accelerator>\n                            <KeyCodeCombination alt=\"UP\" code=\"P\" control=\"UP\" meta=\"UP\" shift=\"UP\" shortcut=\"DOWN\"/>\n                        </accelerator>\n                    </MenuItem>\n                    <MenuItem fx:id=\"showBroadcastWindow\" text=\"%main.menu.broadcast\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2r-radio-tower\"/>\n                        </graphic>\n                    </MenuItem>\n                    <MenuItem fx:id=\"showSharesWindow\" text=\"%main.menu.shares\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-folder-multiple\"/>\n                        </graphic>\n                    </MenuItem>\n                    <MenuItem fx:id=\"statistics\" mnemonicParsing=\"false\" text=\"%main.menu.statistics\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2c-chart-line\"/>\n                        </graphic>\n                        <accelerator>\n                            <KeyCodeCombination alt=\"UP\" code=\"F5\" control=\"UP\" meta=\"UP\" shift=\"UP\" shortcut=\"UP\"/>\n                        </accelerator>\n                    </MenuItem>\n                    <Menu text=\"%main.menu.tools\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2t-tools\"/>\n                        </graphic>\n                        <MenuItem fx:id=\"importFriends\" mnemonicParsing=\"false\" text=\"%main.menu.tools.import-from-rs\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2i-import\"/>\n                            </graphic>\n                        </MenuItem>\n                        <MenuItem fx:id=\"exportBackup\" mnemonicParsing=\"false\" text=\"%main.menu.tools.export\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2e-export\"/>\n                            </graphic>\n                        </MenuItem>\n                        <SeparatorMenuItem fx:id=\"debugSeparator\" mnemonicParsing=\"false\" visible=\"false\"/>\n                        <Menu fx:id=\"debug\" text=\"_Debug\" visible=\"false\">\n                            <MenuItem fx:id=\"launchWebInterface\" mnemonicParsing=\"false\" text=\"Web Interface\">\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2w-web\"/>\n                                </graphic>\n                            </MenuItem>\n                            <MenuItem fx:id=\"launchSwagger\" mnemonicParsing=\"false\" text=\"REST API\">\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2a-api\"/>\n                                </graphic>\n                            </MenuItem>\n                            <MenuItem fx:id=\"h2Console\" text=\"H2 Console\">\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2d-database\"/>\n                                </graphic>\n                            </MenuItem>\n                            <MenuItem fx:id=\"openShell\" text=\"Shell\">\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2p-powershell\"/>\n                                </graphic>\n                            </MenuItem>\n                            <MenuItem fx:id=\"runGc\" text=\"_Run GC\">\n                                <graphic>\n                                    <FontIcon iconLiteral=\"mdi2t-trash-can\"/>\n                                </graphic>\n                            </MenuItem>\n                            <Menu text=\"Requesters\">\n                                <MenuItem fx:id=\"showThemeExample\" text=\"Show Theme Example\"/>\n                                <MenuItem fx:id=\"showErrorException\" text=\"Show Exception Error\"/>\n                                <MenuItem fx:id=\"showError\" text=\"Show Error\"/>\n                            </Menu>\n                        </Menu>\n                    </Menu>\n                    <SeparatorMenuItem mnemonicParsing=\"false\"/>\n                    <Menu text=\"%help\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2h-help-circle-outline\"/>\n                        </graphic>\n                        <MenuItem fx:id=\"showAboutWindow\" text=\"%main.menu.help.about\">\n                            <graphic>\n                                <ImageView fitHeight=\"20.0\" fitWidth=\"20.0\" pickOnBounds=\"true\" preserveRatio=\"true\">\n                                    <Image url=\"@../image/trayicon.png\"/>\n                                </ImageView>\n                            </graphic>\n                        </MenuItem>\n                        <MenuItem fx:id=\"showDocumentation\" text=\"%main.menu.help.documentation\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2b-book-open-variant\"/>\n                            </graphic>\n                            <accelerator>\n                                <KeyCodeCombination alt=\"UP\" code=\"F1\" control=\"UP\" meta=\"UP\" shift=\"UP\" shortcut=\"UP\"/>\n                            </accelerator>\n                        </MenuItem>\n                        <MenuItem fx:id=\"reportBug\" mnemonicParsing=\"false\" text=\"%main.menu.help.report-bug\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2b-bug\"/>\n                            </graphic>\n                        </MenuItem>\n                        <MenuItem fx:id=\"versionCheck\" text=\"%main.menu.help.check-for-updates\">\n                            <graphic>\n                                <FontIcon iconLiteral=\"mdi2u-update\"/>\n                            </graphic>\n                        </MenuItem>\n                    </Menu>\n                    <MenuItem fx:id=\"showSettingsWindow\" text=\"%settings\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2c-cog-outline\"/>\n                        </graphic>\n                        <accelerator>\n                            <KeyCodeCombination alt=\"UP\" code=\"COMMA\" control=\"UP\" meta=\"UP\" shift=\"UP\" shortcut=\"DOWN\"/>\n                        </accelerator>\n                    </MenuItem>\n                    <MenuItem fx:id=\"exitApplication\" text=\"%exit\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2c-close\"/>\n                        </graphic>\n                    </MenuItem>\n                </Menu>\n            </MenuBar>\n        </AnchorPane>\n        <HBox alignment=\"BOTTOM_LEFT\">\n            <VBox.margin>\n                <Insets bottom=\"4.0\" left=\"4.0\" right=\"4.0\" top=\"4.0\"/>\n            </VBox.margin>\n            <Label text=\"%main.status.connections\"/>\n            <Label text=\" \"/>\n            <Label fx:id=\"numberOfConnections\" prefWidth=\"40.0\"/>\n            <Label text=\"NAT: \"/>\n            <LedControl fx:id=\"natStatus\" prefHeight=\"16.0\" prefWidth=\"16.0\"/>\n            <Label text=\"DHT: \">\n                <HBox.margin>\n                    <Insets left=\"8.0\"/>\n                </HBox.margin>\n            </Label>\n            <LedControl fx:id=\"dhtStatus\" prefHeight=\"16.0\" prefWidth=\"16.0\"/>\n            <Spacer HBox.hgrow=\"ALWAYS\"/>\n            <HBox fx:id=\"hashingStatus\" visible=\"false\">\n                <ProgressIndicator prefWidth=\"16.0\" prefHeight=\"16.0\"/>\n                <Label fx:id=\"hashingName\" prefWidth=\"320\">\n                    <HBox.margin>\n                        <Insets left=\"8.0\"/>\n                    </HBox.margin>\n                </Label>\n            </HBox>\n        </HBox>\n    </VBox>\n</StackPane>"
  },
  {
    "path": "ui/src/main/resources/view/messaging/broadcast.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.TextArea?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.text.*?>\n<VBox minHeight=\"220.0\" minWidth=\"320.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.messaging.BroadcastWindowController\" styleClass=\"base-spacing\">\n    <TextFlow>\n        <Text text=\"%broadcast.send.explanation\"/>\n        <Text text=\"&#10;\"/>\n        <Text fill=\"red\" style=\"-fx-font-weight: bold;\" text=\"%broadcast.send.warning-header\"/>\n        <Text text=\" \"/>\n        <Text text=\"%broadcast.send.warning\"/>\n    </TextFlow>\n    <TextArea fx:id=\"textArea\" prefHeight=\"88.0\" prefWidth=\"384.0\" VBox.vgrow=\"ALWAYS\">\n        <VBox.margin>\n            <Insets bottom=\"4.0\" top=\"8.0\"/>\n        </VBox.margin>\n    </TextArea>\n    <HBox alignment=\"TOP_RIGHT\">\n        <VBox.margin>\n            <Insets top=\"4.0\"/>\n        </VBox.margin>\n        <Button fx:id=\"send\" disable=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%send\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"cancel\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%cancel\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/messaging/messaging.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Message?>\n<?import io.xeres.ui.custom.InputAreaGroup?>\n<?import io.xeres.ui.custom.TypingNotificationView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox fx:id=\"content\" minHeight=\"300.0\" minWidth=\"300.0\" prefHeight=\"650.0\" prefWidth=\"500.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\">\n    <Message fx:id=\"notice\" title=\"%messaging.warning.title\" description=\"%messaging.warning.description\" managed=\"false\" visible=\"false\" styleClass=\"warning\">\n        <VBox.margin>\n            <Insets bottom=\"8.0\"/>\n        </VBox.margin>\n        <graphic>\n            <FontIcon iconLiteral=\"mdi2f-flag-variant\"/>\n        </graphic>\n    </Message>\n    <TypingNotificationView fx:id=\"notification\" VBox.vgrow=\"NEVER\"/>\n    <InputAreaGroup fx:id=\"send\">\n        <VBox.margin>\n            <Insets top=\"4.0\"/>\n        </VBox.margin>\n    </InputAreaGroup>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/printer.css",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/* This file is used for the printing support */\n\n.credit-card {\n    -fx-background-color: rgba(255, 255, 255, 0);\n    -fx-background-radius: 19px;\n    -fx-border-color: black;\n    -fx-border-radius: 19px;\n    -fx-padding: 19px;\n}\n\n.print-title {\n    -fx-text-fill: #000;\n    -fx-font-size: 18px;\n    -fx-font-weight: bold;\n    -fx-padding: 0 0 0 5;\n}\n\n.print-label {\n    -fx-text-fill: #333;\n}\n\n.print-value {\n    -fx-fill: #000;\n    -fx-font-size: 12px;\n    -fx-font-weight: bold;\n}\n\n.print-url {\n    -fx-text-fill: #000;\n    -fx-font-size: 11px;\n    -fx-font-weight: bold;\n}\n"
  },
  {
    "path": "ui/src/main/resources/view/qrcode/camera.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.control.Label?>\n<?import javafx.scene.image.ImageView?>\n<?import javafx.scene.layout.StackPane?>\n<StackPane prefWidth=\"640.0\" prefHeight=\"480.0\" minWidth=\"640.0\" minHeight=\"480.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.qrcode.CameraWindowController\">\n    <ImageView fx:id=\"capturedImage\" fitWidth=\"640\" fitHeight=\"480\" pickOnBounds=\"true\" preserveRatio=\"true\"/>\n    <Label fx:id=\"error\" visible=\"false\"/>\n</StackPane>"
  },
  {
    "path": "ui/src/main/resources/view/qrcode/qrcode.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import io.xeres.ui.custom.ResizeableImageView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.Label?>\n<?import javafx.scene.layout.*?>\n<VBox alignment=\"CENTER\" prefWidth=\"360.0\" prefHeight=\"384.0\" minWidth=\"282.0\" minHeight=\"354.0\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.qrcode.QrCodeWindowController\">\n    <ResizeableImageView fx:id=\"ownQrCode\" pickOnBounds=\"true\" preserveRatio=\"true\" VBox.vgrow=\"ALWAYS\"/>\n    <Region VBox.vgrow=\"SOMETIMES\"/>\n    <Label fx:id=\"status\"/>\n    <HBox alignment=\"TOP_RIGHT\">\n        <Button fx:id=\"printButton\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%qr-code.print\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"saveButton\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%save-as\">\n            <HBox.margin>\n                <Insets right=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n        <Button fx:id=\"closeButton\" cancelButton=\"true\" minWidth=\"72.0\" mnemonicParsing=\"false\" text=\"%close\">\n            <HBox.margin>\n                <Insets left=\"4.0\"/>\n            </HBox.margin>\n        </Button>\n    </HBox>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/qrcode/qrprint.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.Label?>\n<?import javafx.scene.image.Image?>\n<?import javafx.scene.image.ImageView?>\n<?import javafx.scene.layout.*?>\n<?import javafx.scene.text.Text?>\n<HBox alignment=\"CENTER\" prefHeight=\"318.75\" prefWidth=\"505.5\" styleClass=\"credit-card\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.qrcode.QrPrintController\" stylesheets=\"@/view/printer.css\">\n    <VBox>\n        <ImageView fitHeight=\"80.0\" fitWidth=\"80.0\" pickOnBounds=\"true\" preserveRatio=\"true\">\n            <HBox.margin>\n                <Insets bottom=\"8.0\" left=\"8.0\" right=\"8.0\" top=\"8.0\"/>\n            </HBox.margin>\n            <Image url=\"@../../image/icon.png\"/>\n        </ImageView>\n        <Label text=\"Xeres ID\" styleClass=\"print-title\"/>\n        <Region prefHeight=\"32.0\"/>\n        <GridPane hgap=\"4\">\n            <columnConstraints>\n                <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\"/>\n            </columnConstraints>\n            <rowConstraints>\n                <RowConstraints minHeight=\"20.0\" prefHeight=\"20.0\" vgrow=\"ALWAYS\"/>\n                <RowConstraints minHeight=\"20.0\" prefHeight=\"20.0\" vgrow=\"ALWAYS\"/>\n            </rowConstraints>\n            <Label text=\"%profile\" styleClass=\"print-label\"/>\n            <Text fx:id=\"profileText\" GridPane.columnIndex=\"1\" styleClass=\"print-value\"/>\n            <Label text=\"%account.location\" GridPane.rowIndex=\"1\" styleClass=\"print-label\"/>\n            <Text fx:id=\"locationText\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\" styleClass=\"print-value\"/>\n        </GridPane>\n        <Region VBox.vgrow=\"ALWAYS\"/>\n        <Label text=\"%qr-code.download-client\" wrapText=\"true\" styleClass=\"print-url\"/>\n    </VBox>\n    <Region HBox.hgrow=\"SOMETIMES\"/>\n    <ImageView fx:id=\"qrCode\" fitHeight=\"256.0\" fitWidth=\"256.0\" pickOnBounds=\"true\" preserveRatio=\"true\"/>\n</HBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.ListView?>\n<?import javafx.scene.layout.*?>\n<AnchorPane maxHeight=\"-Infinity\" maxWidth=\"-Infinity\" minHeight=\"422.0\" minWidth=\"640.0\" prefHeight=\"600.0\" prefWidth=\"800.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.settings.SettingsWindowController\" styleClass=\"base-spacing\">\n    <HBox layoutX=\"173.0\" layoutY=\"137.0\" prefHeight=\"100.0\" prefWidth=\"200.0\" AnchorPane.bottomAnchor=\"0.0\" AnchorPane.leftAnchor=\"0.0\" AnchorPane.rightAnchor=\"0.0\" AnchorPane.topAnchor=\"0.0\">\n        <ListView fx:id=\"listView\" minWidth=\"200\" prefHeight=\"200.0\" prefWidth=\"200.0\"/>\n        <AnchorPane fx:id=\"content\" prefHeight=\"200.0\" prefWidth=\"200.0\" HBox.hgrow=\"ALWAYS\">\n            <padding>\n                <Insets left=\"12.0\"/>\n            </padding>\n        </AnchorPane>\n    </HBox>\n</AnchorPane>\n"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings_general.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Card?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox maxHeight=\"-Infinity\" maxWidth=\"-Infinity\" spacing=\"12.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.settings.SettingsGeneralController\">\n    <Card>\n        <header>\n            <Label text=\"%settings.general.theme\" styleClass=\"title-4\"/>\n        </header>\n        <body>\n            <ComboBox fx:id=\"themeSelector\"/>\n        </body>\n    </Card>\n    <Card>\n        <header>\n            <Label text=\"%settings.general.system\" styleClass=\"title-4\"/>\n        </header>\n        <body>\n            <GridPane hgap=\"8\" vgap=\"8\">\n                <columnConstraints>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                    <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                </columnConstraints>\n                <rowConstraints>\n                    <RowConstraints vgrow=\"SOMETIMES\"/>\n                    <RowConstraints/>\n                </rowConstraints>\n                <CheckBox fx:id=\"checkForUpdates\" text=\"%settings.general.update-check\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.general.update-check.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <CheckBox fx:id=\"autoStartEnabled\" disable=\"true\" mnemonicParsing=\"false\" text=\"%settings.general.startup\" GridPane.rowIndex=\"1\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.general.startup.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <Label fx:id=\"autoStartNotAvailable\" text=\"%settings.general.startup.not-available\" visible=\"false\" wrapText=\"true\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\" styleClass=\"text-small\"/>\n            </GridPane>\n        </body>\n    </Card>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings_networks.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Card?>\n<?import atlantafx.base.layout.InputGroup?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<ScrollPane fitToWidth=\"true\" prefHeight=\"300.0\" prefWidth=\"400.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.settings.SettingsNetworksController\">\n    <VBox spacing=\"12.0\">\n        <Card>\n            <header>\n                <Label text=\"%settings.network.hidden-services\" styleClass=\"title-4\"/>\n            </header>\n            <body>\n                <GridPane hgap=\"8\" vgap=\"8\">\n                    <columnConstraints>\n                        <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                        <ColumnConstraints hgrow=\"SOMETIMES\"/>\n                    </columnConstraints>\n                    <rowConstraints>\n                        <RowConstraints vgrow=\"ALWAYS\"/>\n                        <RowConstraints vgrow=\"ALWAYS\"/>\n                    </rowConstraints>\n                    <Label text=\"%settings.network.tor-proxy\">\n                        <GridPane.margin>\n                            <Insets right=\"4.0\"/>\n                        </GridPane.margin>\n                    </Label>\n                    <InputGroup GridPane.columnIndex=\"1\">\n                        <TextField fx:id=\"torSocksHost\" promptText=\"%settings.network.tor-proxy.prompt\" HBox.hgrow=\"ALWAYS\">\n                            <tooltip>\n                                <Tooltip maxWidth=\"300\" wrapText=\"true\" showDuration=\"1m\" text=\"%settings.network.tor-proxy.tip\"/>\n                            </tooltip>\n                        </TextField>\n                        <TextField fx:id=\"torSocksPort\" promptText=\"%port\" minWidth=\"65.0\" prefWidth=\"70.0\">\n                            <tooltip>\n                                <Tooltip maxWidth=\"300\" wrapText=\"true\" showDuration=\"1m\" text=\"%settings.network.tor-port.tip\"/>\n                            </tooltip>\n                        </TextField>\n                    </InputGroup>\n\n                    <Label text=\"%settings.network.i2p-proxy\" GridPane.rowIndex=\"1\">\n                        <GridPane.margin>\n                            <Insets right=\"4.0\"/>\n                        </GridPane.margin>\n                    </Label>\n                    <InputGroup GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n                        <TextField fx:id=\"i2pSocksHost\" promptText=\"%settings.network.i2p-proxy.prompt\" HBox.hgrow=\"ALWAYS\">\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.i2p-proxy.tip\"/>\n                            </tooltip>\n                        </TextField>\n                        <TextField fx:id=\"i2pSocksPort\" promptText=\"%port\" minWidth=\"65.0\" prefWidth=\"70.0\">\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.i2p-port.tip\"/>\n                            </tooltip>\n                        </TextField>\n                    </InputGroup>\n                </GridPane>\n            </body>\n        </Card>\n        <Card>\n            <header>\n                <Label text=\"LAN\" styleClass=\"title-4\"/>\n            </header>\n            <body>\n                <GridPane hgap=\"8\" vgap=\"8\">\n                    <columnConstraints>\n                        <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                        <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                    </columnConstraints>\n                    <rowConstraints>\n                        <RowConstraints vgrow=\"SOMETIMES\"/>\n                        <RowConstraints vgrow=\"SOMETIMES\"/>\n                    </rowConstraints>\n                    <CheckBox fx:id=\"broadcastDiscoveryEnabled\" mnemonicParsing=\"false\" text=\"%settings.network.use-broadcast-discovery\" GridPane.columnSpan=\"2147483647\">\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.use-broadcast-discovery.tip\"/>\n                        </tooltip>\n                    </CheckBox>\n                    <Label text=\"%settings.network.internal-ip-and-port\" GridPane.rowIndex=\"1\">\n                        <GridPane.margin>\n                            <Insets right=\"4.0\"/>\n                        </GridPane.margin>\n                    </Label>\n                    <InputGroup GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n                        <ReadOnlyTextField fx:id=\"internalIp\" HBox.hgrow=\"ALWAYS\">\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.internal-ip-and-port.tip\"/>\n                            </tooltip>\n                        </ReadOnlyTextField>\n                        <ReadOnlyTextField fx:id=\"internalPort\" minWidth=\"65.0\" prefWidth=\"70.0\"/>\n                    </InputGroup>\n                </GridPane>\n            </body>\n        </Card>\n        <Card>\n            <header>\n                <Label text=\"NAT\" styleClass=\"title-4\"/>\n            </header>\n            <body>\n                <GridPane hgap=\"8\" vgap=\"8\">\n                    <columnConstraints>\n                        <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                        <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                    </columnConstraints>\n                    <rowConstraints>\n                        <RowConstraints vgrow=\"SOMETIMES\"/>\n                        <RowConstraints vgrow=\"SOMETIMES\"/>\n                    </rowConstraints>\n                    <CheckBox fx:id=\"upnpEnabled\" mnemonicParsing=\"false\" text=\"%settings.network.use-upnp\" GridPane.columnSpan=\"2147483647\">\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.use-upnp.tip\"/>\n                        </tooltip>\n                    </CheckBox>\n                    <Label text=\"%settings.network.external-ip-and-port\" GridPane.rowIndex=\"1\">\n                        <GridPane.margin>\n                            <Insets right=\"4.0\"/>\n                        </GridPane.margin>\n                    </Label>\n                    <InputGroup GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\">\n                        <ReadOnlyTextField fx:id=\"externalIp\" HBox.hgrow=\"ALWAYS\">\n                            <tooltip>\n                                <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.external-ip-and-port.tip\"/>\n                            </tooltip>\n                        </ReadOnlyTextField>\n                        <ReadOnlyTextField fx:id=\"externalPort\" minWidth=\"65.0\" prefWidth=\"70.0\"/>\n                    </InputGroup>\n                </GridPane>\n            </body>\n        </Card>\n        <Card>\n            <header>\n                <Label text=\"DHT\" styleClass=\"title-4\"/>\n            </header>\n            <body>\n                <GridPane hgap=\"8\" vgap=\"8\">\n                    <columnConstraints>\n                        <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                        <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                    </columnConstraints>\n                    <rowConstraints>\n                        <RowConstraints vgrow=\"SOMETIMES\"/>\n                    </rowConstraints>\n                    <CheckBox fx:id=\"dhtEnabled\" mnemonicParsing=\"false\" text=\"%settings.network.use-dht\" GridPane.columnSpan=\"2147483647\">\n                        <tooltip>\n                            <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.network.use-dht.tip\"/>\n                        </tooltip>\n                    </CheckBox>\n                </GridPane>\n            </body>\n        </Card>\n    </VBox>\n</ScrollPane>\n"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings_notifications.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2019-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Card?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<ScrollPane fitToWidth=\"true\" prefHeight=\"300.0\" prefWidth=\"400.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.settings.SettingsNotificationController\">\n    <Card>\n        <header>\n            <Label text=\"%settings.notifications\" styleClass=\"title-4\"/>\n        </header>\n        <body>\n            <GridPane hgap=\"8\" vgap=\"8\">\n                <columnConstraints>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                    <ColumnConstraints hgrow=\"SOMETIMES\" minWidth=\"10.0\" prefWidth=\"100.0\"/>\n                </columnConstraints>\n                <rowConstraints>\n                    <RowConstraints vgrow=\"SOMETIMES\"/>\n                </rowConstraints>\n                <CheckBox fx:id=\"showConnections\" text=\"%settings.notifications.show-connections\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.notifications.show-connections.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <CheckBox fx:id=\"showBroadcasts\" text=\"%settings.notifications.show-broadcasts\" GridPane.rowIndex=\"1\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.notifications.show-broadcasts.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <CheckBox fx:id=\"showDiscovery\" text=\"%settings.notifications.show-discovery\" GridPane.rowIndex=\"2\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.notifications.show-discovery.tip\"/>\n                    </tooltip>\n                </CheckBox>\n            </GridPane>\n        </body>\n    </Card>\n</ScrollPane>"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings_remote.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Card?>\n<?import atlantafx.base.controls.PasswordTextField?>\n<?import io.xeres.ui.custom.DisclosedHyperlink?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<VBox xmlns=\"http://javafx.com/javafx\"\n      xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.settings.SettingsRemoteController\">\n    <Card>\n        <header>\n            <Label text=\"%settings.remote.title\" styleClass=\"title-4\"/>\n        </header>\n        <body>\n            <GridPane hgap=\"8\" vgap=\"8\">\n                <columnConstraints>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                    <ColumnConstraints hgrow=\"SOMETIMES\" maxWidth=\"1.7976931348623157E308\"/>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                </columnConstraints>\n                <rowConstraints>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                </rowConstraints>\n                <CheckBox fx:id=\"remoteEnabled\" mnemonicParsing=\"false\" text=\"%enabled\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.remote.enabled.tip\"/>\n                    </tooltip>\n                </CheckBox>\n\n                <DisclosedHyperlink GridPane.halignment=\"RIGHT\" fx:id=\"viewApi\" GridPane.columnIndex=\"1\" text=\"%settings.remote.view-api\" styleClass=\"text-small\" alwaysSafe=\"true\"/>\n\n                <Label text=\"%port\" GridPane.rowIndex=\"1\">\n                    <GridPane.margin>\n                        <Insets right=\"4.0\"/>\n                    </GridPane.margin>\n                </Label>\n                <TextField fx:id=\"port\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"1\"/>\n                <CheckBox fx:id=\"remoteUpnpEnabled\" mnemonicParsing=\"false\" text=\"%settings.remote.upnp-set\" GridPane.rowIndex=\"1\" GridPane.columnIndex=\"2\" GridPane.columnSpan=\"2147483647\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.remote.upnp-set.tip\"/>\n                    </tooltip>\n                </CheckBox>\n\n                <Label text=\"%settings.remote.username\" GridPane.rowIndex=\"2\">\n                    <GridPane.margin>\n                        <Insets right=\"4.0\"/>\n                    </GridPane.margin>\n                </Label>\n                <ReadOnlyTextField fx:id=\"username\" text=\"user\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"2\" GridPane.columnSpan=\"2147483647\"/>\n                <Label text=\"%settings.remote.password\" GridPane.rowIndex=\"3\">\n                    <GridPane.margin>\n                        <Insets right=\"4.0\"/>\n                    </GridPane.margin>\n                </Label>\n                <PasswordTextField fx:id=\"password\" GridPane.columnIndex=\"1\" GridPane.rowIndex=\"3\" GridPane.columnSpan=\"2147483647\" bullet=\"●\" styleClass=\"fixed-font\"/>\n                <Label wrapText=\"true\" GridPane.rowIndex=\"5\" GridPane.columnIndex=\"1\" GridPane.columnSpan=\"2147483647\" styleClass=\"text-small\" text=\"%settings.remote.note\"/>\n            </GridPane>\n        </body>\n    </Card>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings_sound.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Card?>\n<?import atlantafx.base.layout.*?>\n<?import io.xeres.ui.custom.*?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.*?>\n<VBox maxHeight=\"-Infinity\" maxWidth=\"-Infinity\" spacing=\"12.0\" xmlns=\"http://javafx.com/javafx\"\n      xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.settings.SettingsSoundController\">\n    <Card>\n        <header>\n            <Label text=\"%settings.sound\" styleClass=\"title-4\"/>\n        </header>\n        <body>\n            <GridPane hgap=\"8\" vgap=\"8\">\n                <columnConstraints>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                    <ColumnConstraints hgrow=\"SOMETIMES\" maxWidth=\"1.7976931348623157E308\"/>\n                    <ColumnConstraints hgrow=\"NEVER\" minWidth=\"10.0\"/>\n                </columnConstraints>\n                <rowConstraints>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                    <RowConstraints vgrow=\"ALWAYS\"/>\n                </rowConstraints>\n                <!-- Message -->\n                <CheckBox fx:id=\"messageEnabled\" text=\"%settings.sound.message\" selected=\"true\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.sound.message.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <InputGroup GridPane.columnIndex=\"1\">\n                    <ReadOnlyTextField fx:id=\"messageFile\" HBox.hgrow=\"ALWAYS\"/>\n                    <Button fx:id=\"messageFileSelector\" styleClass=\"button-icon\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-folder-open\"/>\n                        </graphic>\n                    </Button>\n                </InputGroup>\n                <Button fx:id=\"messagePlay\" styleClass=\"button-icon\" GridPane.columnIndex=\"2\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-play\"/>\n                    </graphic>\n                </Button>\n\n                <!-- Highlight -->\n                <CheckBox fx:id=\"highlightEnabled\" GridPane.rowIndex=\"1\" text=\"%settings.sound.highlight\" selected=\"true\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.sound.highlight.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <InputGroup GridPane.rowIndex=\"1\" GridPane.columnIndex=\"1\">\n                    <ReadOnlyTextField fx:id=\"highlightFile\" HBox.hgrow=\"ALWAYS\"/>\n                    <Button fx:id=\"highlightFileSelector\" styleClass=\"button-icon\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-folder-open\"/>\n                        </graphic>\n                    </Button>\n                </InputGroup>\n                <Button fx:id=\"highlightPlay\" styleClass=\"button-icon\" GridPane.rowIndex=\"1\" GridPane.columnIndex=\"2\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-play\"/>\n                    </graphic>\n                </Button>\n\n                <!-- Friend connected -->\n                <CheckBox fx:id=\"friendEnabled\" GridPane.rowIndex=\"2\" text=\"%settings.sound.friend\" selected=\"true\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.sound.friend.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <InputGroup GridPane.rowIndex=\"2\" GridPane.columnIndex=\"1\">\n                    <ReadOnlyTextField fx:id=\"friendFile\" HBox.hgrow=\"ALWAYS\"/>\n                    <Button fx:id=\"friendFileSelector\" styleClass=\"button-icon\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-folder-open\"/>\n                        </graphic>\n                    </Button>\n                </InputGroup>\n                <Button fx:id=\"friendPlay\" styleClass=\"button-icon\" GridPane.rowIndex=\"2\" GridPane.columnIndex=\"2\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-play\"/>\n                    </graphic>\n                </Button>\n\n                <!-- Download complete -->\n                <CheckBox fx:id=\"downloadEnabled\" GridPane.rowIndex=\"3\" text=\"%settings.sound.download\" selected=\"true\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.sound.download.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <InputGroup GridPane.rowIndex=\"3\" GridPane.columnIndex=\"1\">\n                    <ReadOnlyTextField fx:id=\"downloadFile\" HBox.hgrow=\"ALWAYS\"/>\n                    <Button fx:id=\"downloadFileSelector\" styleClass=\"button-icon\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-folder-open\"/>\n                        </graphic>\n                    </Button>\n                </InputGroup>\n                <Button fx:id=\"downloadPlay\" styleClass=\"button-icon\" GridPane.rowIndex=\"3\" GridPane.columnIndex=\"2\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-play\"/>\n                    </graphic>\n                </Button>\n\n                <!-- Ringing -->\n                <CheckBox fx:id=\"ringingEnabled\" GridPane.rowIndex=\"4\" text=\"%settings.sound.ringing\" selected=\"true\">\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%settings.sound.ringing.tip\"/>\n                    </tooltip>\n                </CheckBox>\n                <InputGroup GridPane.rowIndex=\"4\" GridPane.columnIndex=\"1\">\n                    <ReadOnlyTextField fx:id=\"ringingFile\" HBox.hgrow=\"ALWAYS\"/>\n                    <Button fx:id=\"ringingFileSelector\" styleClass=\"button-icon\">\n                        <graphic>\n                            <FontIcon iconLiteral=\"mdi2f-folder-open\"/>\n                        </graphic>\n                    </Button>\n                </InputGroup>\n                <Button fx:id=\"ringingPlay\" styleClass=\"button-icon\" GridPane.rowIndex=\"4\" GridPane.columnIndex=\"2\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-play\"/>\n                    </graphic>\n                </Button>\n            </GridPane>\n        </body>\n    </Card>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/settings/settings_transfer.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2023 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import atlantafx.base.controls.Card?>\n<?import atlantafx.base.layout.InputGroup?>\n<?import io.xeres.ui.custom.ReadOnlyTextField?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.Label?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox maxHeight=\"-Infinity\" maxWidth=\"-Infinity\" spacing=\"12.0\" xmlns=\"http://javafx.com/javafx\"\n      xmlns:fx=\"http://javafx.com/fxml\"\n      fx:controller=\"io.xeres.ui.controller.settings.SettingsTransferController\">\n    <Card>\n        <header>\n            <Label text=\"%settings.transfer.incoming\" styleClass=\"title-4\"/>\n        </header>\n        <body>\n            <InputGroup>\n                <ReadOnlyTextField fx:id=\"incomingDirectory\" HBox.hgrow=\"ALWAYS\"/>\n                <Button fx:id=\"incomingDirectorySelector\" styleClass=\"button-icon\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2f-folder-open\"/>\n                    </graphic>\n                </Button>\n            </InputGroup>\n        </body>\n    </Card>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/statistics/datacounter.fxml",
    "content": "<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.chart.BarChart?>\n<?import javafx.scene.chart.CategoryAxis?>\n<?import javafx.scene.chart.NumberAxis?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.Tooltip?>\n<?import javafx.scene.Cursor?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.statistics.StatisticsDataCounterController\">\n    <HBox alignment=\"BASELINE_RIGHT\">\n        <Button styleClass=\"button-icon, flat, accent\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2i-information\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDelay=\"0ms\" showDuration=\"1m\" maxWidth=\"400\" wrapText=\"true\" text=\"%statistics.data-counter.tip\"/>\n            </tooltip>\n        </Button>\n    </HBox>\n    <BarChart fx:id=\"barChart\" title=\"%statistics.data-counter.title\" animated=\"false\" VBox.vgrow=\"ALWAYS\" barGap=\"0.0\" categoryGap=\"20.0\">\n        <xAxis>\n            <CategoryAxis fx:id=\"xAxis\" label=\"%statistics.data-counter.peers\" animated=\"false\"/>\n        </xAxis>\n        <yAxis>\n            <NumberAxis label=\"%statistics.data-counter.data\" animated=\"false\"/>\n        </yAxis>\n        <cursor>\n            <Cursor fx:constant=\"CROSSHAIR\"/>\n        </cursor>\n    </BarChart>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/statistics/main.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.control.Tab?>\n<?import javafx.scene.control.TabPane?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox minWidth=\"320.0\" minHeight=\"260.0\" prefWidth=\"800.0\" prefHeight=\"600.0\" alignment=\"CENTER\" styleClass=\"base-spacing\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.statistics.StatisticsMainWindowController\">\n    <TabPane fx:id=\"tabPane\" tabClosingPolicy=\"UNAVAILABLE\" VBox.vgrow=\"ALWAYS\">\n        <Tab text=\"%statistics.turtle\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2t-turtle\"/>\n            </graphic>\n            <!--suppress JavaFxUnresolvedFxIdReference -->\n            <fx:include fx:id=\"statisticsTurtle\" source=\"turtle.fxml\"/>\n        </Tab>\n        <Tab text=\"%statistics.rtt\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2t-timer\"/>\n            </graphic>\n            <!--suppress JavaFxUnresolvedFxIdReference -->\n            <fx:include fx:id=\"statisticsRtt\" source=\"rtt.fxml\"/>\n        </Tab>\n        <Tab text=\"%statistics.data-usage\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2p-pipe\"/>\n            </graphic>\n            <!--suppress JavaFxUnresolvedFxIdReference -->\n            <fx:include fx:id=\"statisticsDataCounter\" source=\"datacounter.fxml\"/>\n        </Tab>\n    </TabPane>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/statistics/rtt.fxml",
    "content": "<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.chart.LineChart?>\n<?import javafx.scene.chart.NumberAxis?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.Tooltip?>\n<?import javafx.scene.Cursor?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.statistics.StatisticsRttController\">\n    <HBox alignment=\"BASELINE_RIGHT\">\n        <Button styleClass=\"button-icon, flat, accent\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2i-information\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDelay=\"0ms\" showDuration=\"1m\" maxWidth=\"400\" wrapText=\"true\" text=\"%statistics.rtt.tip\"/>\n            </tooltip>\n        </Button>\n    </HBox>\n    <LineChart fx:id=\"lineChart\" title=\"%statistics.rtt.rtt\" animated=\"false\" createSymbols=\"false\" VBox.vgrow=\"ALWAYS\">\n        <xAxis>\n            <NumberAxis fx:id=\"xAxis\" label=\"%statistics.elapsed-time\" animated=\"false\" lowerBound=\"-120\" tickUnit=\"10\" upperBound=\"0\" autoRanging=\"false\"/>\n        </xAxis>\n        <yAxis>\n            <NumberAxis label=\"%statistics.rtt.time\" animated=\"false\"/>\n        </yAxis>\n        <cursor>\n            <Cursor fx:constant=\"CROSSHAIR\"/>\n        </cursor>\n    </LineChart>\n</VBox>"
  },
  {
    "path": "ui/src/main/resources/view/statistics/turtle.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n<?import javafx.scene.chart.LineChart?>\n<?import javafx.scene.chart.NumberAxis?>\n<?import javafx.scene.control.Button?>\n<?import javafx.scene.control.Tooltip?>\n<?import javafx.scene.Cursor?>\n<?import javafx.scene.layout.HBox?>\n<?import javafx.scene.layout.VBox?>\n<?import org.kordamp.ikonli.javafx.FontIcon?>\n<VBox xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.statistics.StatisticsTurtleController\">\n    <HBox alignment=\"BASELINE_RIGHT\">\n        <Button styleClass=\"button-icon, flat, accent\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2i-information\"/>\n            </graphic>\n            <tooltip>\n                <Tooltip showDelay=\"0ms\" showDuration=\"1m\" maxWidth=\"400\" wrapText=\"true\" text=\"%statistics.turtle.tip\"/>\n            </tooltip>\n        </Button>\n    </HBox>\n    <LineChart fx:id=\"lineChart\" title=\"%statistics.turtle.bandwidth\" animated=\"false\" createSymbols=\"false\" VBox.vgrow=\"ALWAYS\">\n        <xAxis>\n            <NumberAxis fx:id=\"xAxis\" label=\"%statistics.elapsed-time\" animated=\"false\" lowerBound=\"-120\" upperBound=\"0\" autoRanging=\"false\"/>\n        </xAxis>\n        <yAxis>\n            <NumberAxis label=\"%statistics.turtle.speed\" animated=\"false\"/>\n        </yAxis>\n        <cursor>\n            <Cursor fx:constant=\"CROSSHAIR\"/>\n        </cursor>\n    </LineChart>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/voip/voip.fxml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<!--\n  ~ Copyright (c) 2025 by David Gerber - https://zapek.com\n  ~\n  ~ This file is part of Xeres.\n  ~\n  ~ Xeres is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation, either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ Xeres is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n  -->\n\n\n<?import atlantafx.base.controls.Spacer?>\n<?import io.xeres.ui.custom.asyncimage.AsyncImageView?>\n<?import javafx.geometry.Insets?>\n<?import javafx.scene.control.*?>\n<?import javafx.scene.layout.*?>\n<?import org.kordamp.ikonli.javafx.*?>\n<VBox alignment=\"CENTER\" minHeight=\"160.0\" minWidth=\"220.0\" prefHeight=\"360.0\" prefWidth=\"560.0\" xmlns=\"http://javafx.com/javafx\" xmlns:fx=\"http://javafx.com/fxml\" fx:controller=\"io.xeres.ui.controller.voip.VoipWindowController\" styleClass=\"base-spacing\">\n    <StackPane VBox.vgrow=\"ALWAYS\">\n        <VBox alignment=\"CENTER\">\n            <AsyncImageView fx:id=\"imageView\" fitWidth=\"128\" fitHeight=\"128\"/>\n            <Label fx:id=\"nameLabel\" styleClass=\"title-1\">\n                <padding>\n                    <Insets top=\"4.0\"/>\n                </padding>\n            </Label>\n            <Label fx:id=\"statusLabel\" styleClass=\"title-4\"/>\n            <Label fx:id=\"timerLabel\" visible=\"false\"/>\n            <HBox alignment=\"CENTER\" spacing=\"12.0\">\n                <padding>\n                    <Insets bottom=\"8.0\" top=\"12.0\"/>\n                </padding>\n                <Button fx:id=\"messageButton\" text=\"%voip.action.message\" managed=\"false\" visible=\"false\" styleClass=\"rounded\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2m-message\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%voip.action.message.tip\"/>\n                    </tooltip>\n                </Button>\n                <Button fx:id=\"recallButton\" text=\"%voip.action.recall\" managed=\"false\" visible=\"false\" styleClass=\"rounded, success\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2p-phone\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%voip.action.recall.tip\"/>\n                    </tooltip>\n                </Button>\n                <Button fx:id=\"closeButton\" text=\"%close\" managed=\"false\" visible=\"false\" styleClass=\"rounded\">\n                    <graphic>\n                        <FontIcon iconLiteral=\"mdi2c-close\"/>\n                    </graphic>\n                    <tooltip>\n                        <Tooltip showDuration=\"1m\" maxWidth=\"300\" wrapText=\"true\" text=\"%voip.action.close.tip\"/>\n                    </tooltip>\n                </Button>\n            </HBox>\n        </VBox>\n    </StackPane>\n    <HBox>\n        <Button fx:id=\"answerButton\" text=\"%voip.action.answer\" styleClass=\"success\" visible=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2p-phone\"/>\n            </graphic>\n        </Button>\n        <Spacer HBox.hgrow=\"ALWAYS\"/>\n        <Button fx:id=\"rejectButton\" text=\"%voip.action.reject\" styleClass=\"danger\" visible=\"false\">\n            <graphic>\n                <FontIcon iconLiteral=\"mdi2p-phone-hangup\"/>\n            </graphic>\n        </Button>\n    </HBox>\n</VBox>\n"
  },
  {
    "path": "ui/src/main/resources/view/windows.css",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n.fixed-font {\n    -fx-font-family: \"Consolas\";\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/FXTest.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui;\n\nimport javafx.application.Platform;\nimport org.junit.jupiter.api.BeforeAll;\n\n/**\n * Extend your test from this abstract class if you don't use testfx's ApplicationExtension class\n * (for example you use Spring Boot's SpringExtension class).<br>\n * Note that depending on how you run the tests, the platform might already be running.\n */\npublic abstract class FXTest\n{\n\t@BeforeAll\n\tstatic void initJfxRuntime()\n\t{\n\t\ttry\n\t\t{\n\t\t\tPlatform.startup(() -> {\n\t\t\t});\n\t\t}\n\t\tcatch (IllegalStateException _)\n\t\t{\n\t\t\t// Platform already running, just ignore\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/UiCodingRulesTest.java",
    "content": "/*\n * Copyright (c) 2024-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui;\n\nimport com.tngtech.archunit.core.domain.JavaClass;\nimport com.tngtech.archunit.core.domain.JavaMethodCall;\nimport com.tngtech.archunit.core.domain.JavaModifier;\nimport com.tngtech.archunit.core.importer.ImportOption;\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\nimport com.tngtech.archunit.lang.ArchCondition;\nimport com.tngtech.archunit.lang.ArchRule;\nimport com.tngtech.archunit.lang.ConditionEvents;\nimport com.tngtech.archunit.lang.SimpleConditionEvent;\nimport io.xeres.common.id.GxsId;\nimport io.xeres.common.id.MsgId;\nimport io.xeres.ui.controller.WindowController;\nimport org.slf4j.Logger;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;\nimport static com.tngtech.archunit.library.GeneralCodingRules.*;\n\n@SuppressWarnings(\"unused\")\n@AnalyzeClasses(packagesOf = JavaFxApplication.class, importOptions = ImportOption.DoNotIncludeTests.class)\nclass UiCodingRulesTest\n{\n\t@ArchTest\n\tprivate final ArchRule noAccessToStandardStreams = noClasses()\n\t\t\t.should(ACCESS_STANDARD_STREAMS)\n\t\t\t.because(\"We use loggers\");\n\n\t@ArchTest\n\tprivate final ArchRule noFieldInjection = NO_CLASSES_SHOULD_USE_FIELD_INJECTION\n\t\t\t.because(\"Constructor injection allow detection of cyclic dependencies\");\n\n\t@ArchTest\n\tprivate final ArchRule noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;\n\n\t@ArchTest\n\tprivate final ArchRule loggersShouldBeFinalAndStatic =\n\t\t\tfields().that().haveRawType(Logger.class)\n\t\t\t\t\t.should().bePrivate().orShould().beProtected()\n\t\t\t\t\t.andShould().beStatic().orShould().beProtected()\n\t\t\t\t\t.andShould().beFinal()\n\t\t\t\t\t.because(\"we agreed on this convention\");\n\n\t@ArchTest\n\tprivate final ArchRule windowNaming = classes()\n\t\t\t.that().implement(WindowController.class)\n\t\t\t.should().haveSimpleNameEndingWith(\"WindowController\");\n\n\t@ArchTest\n\tprivate final ArchRule utilityClass = classes()\n\t\t\t.that().haveSimpleNameEndingWith(\"Utils\")\n\t\t\t.should(new ArchCondition<>(\"have a private constructor without parameters, that throws UnsupportedOperationException\")\n\t\t\t        {\n\t\t\t\t        @Override\n\t\t\t\t        public void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t        {\n\t\t\t\t\t        boolean satisfied = javaClass.getConstructors().stream()\n\t\t\t\t\t\t\t        .anyMatch(constructor ->\n\t\t\t\t\t\t\t\t\t        constructor.getModifiers().contains(JavaModifier.PRIVATE)\n\t\t\t\t\t\t\t\t\t\t\t        && constructor.getParameters().isEmpty()\n\t\t\t\t\t\t\t        );\n\t\t\t\t\t        String message = javaClass.getDescription() + (satisfied ? \" has\" : \" does not have\")\n\t\t\t\t\t\t\t        + \" a private constructor without parameters\";\n\t\t\t\t\t        events.add(new SimpleConditionEvent(javaClass, satisfied, message));\n\t\t\t\t        }\n\t\t\t        }\n\t\t\t)\n\t\t\t.andShould().haveModifier(JavaModifier.FINAL);\n\n\t@ArchTest\n\tprivate final ArchRule noDirectInitialDirectoryCalls = noClasses()\n\t\t\t.should(new ArchCondition<>(\"call FileChooser or DirectoryChooser's setInitialDirectory() directly but use ChooserUtils\")\n\t\t\t        {\n\t\t\t\t        @Override\n\t\t\t\t        public void check(JavaClass javaClass, ConditionEvents events)\n\t\t\t\t        {\n\t\t\t\t\t        for (JavaMethodCall call : javaClass.getMethodCallsFromSelf())\n\t\t\t\t\t        {\n\t\t\t\t\t\t        String targetName = call.getTarget().getName();\n\t\t\t\t\t\t        if (!\"setInitialDirectory\".equals(targetName))\n\t\t\t\t\t\t        {\n\t\t\t\t\t\t\t        continue;\n\t\t\t\t\t\t        }\n\t\t\t\t\t\t        String owner = call.getTargetOwner().getFullName();\n\t\t\t\t\t\t        if (\"javafx.stage.FileChooser\".equals(owner) || \"javafx.stage.DirectoryChooser\".equals(owner))\n\t\t\t\t\t\t        {\n\t\t\t\t\t\t\t        if (\"ChooserUtils\".equals(javaClass.getSimpleName()))\n\t\t\t\t\t\t\t        {\n\t\t\t\t\t\t\t\t        continue;\n\t\t\t\t\t\t\t        }\n\t\t\t\t\t\t\t        String message = javaClass.getDescription() + \" calls \" + owner + \".setInitialDirectory\";\n\t\t\t\t\t\t\t        events.add(new SimpleConditionEvent(javaClass, true, message));\n\t\t\t\t\t\t        }\n\t\t\t\t\t        }\n\t\t\t\t        }\n\t\t\t        }\n\t\t\t)\n\t\t\t.because(\"the Chooser would fail to open if the directory doesn't exist\");\n\n\t@ArchTest\n\tprivate final ArchRule gxsIdFieldNaming =\n\t\t\tfields().that().haveRawType(GxsId.class)\n\t\t\t\t\t.should().haveNameEndingWith(\"GxsId\")\n\t\t\t\t\t.orShould().haveName(\"gxsId\")\n\t\t\t\t\t.because(\"The name could be confused with database IDs\");\n\n\t@ArchTest\n\tprivate final ArchRule msgIdFieldNaming =\n\t\t\tfields().that().haveRawType(MsgId.class)\n\t\t\t\t\t.should().haveNameEndingWith(\"MsgId\")\n\t\t\t\t\t.orShould().haveName(\"msgId\")\n\t\t\t\t\t.because(\"The name could be confused with database IDs\");\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/client/PaginatedResponseTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.client;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PaginatedResponseTest\n{\n\t@Test\n\tvoid testPaginatedResponse()\n\t{\n\t\tvar page = new PaginatedResponse<>(List.of(\"a\", \"b\", \"c\"), new PaginatedResponse.PaginatedPage(3, 1, 0, 3));\n\n\t\tassertFalse(page.empty());\n\t\tassertTrue(page.first());\n\t\tassertFalse(page.last());\n\t\tassertEquals(3, page.numberOfElements());\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/about/AboutWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.about;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport javafx.application.HostServices;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.boot.info.BuildProperties;\nimport org.springframework.core.env.Environment;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass AboutWindowControllerTest\n{\n\t@Mock\n\tprivate BuildProperties buildProperties;\n\n\t@Mock\n\tprivate Environment environment;\n\n\t@Mock\n\tprivate HostServices hostServices;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate AboutWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(AboutWindowControllerTest.class.getResource(\"/view/about/about.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/account/AccountCreationWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.account;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.rest.config.HostnameResponse;\nimport io.xeres.common.rest.config.UsernameResponse;\nimport io.xeres.ui.client.ConfigClient;\nimport io.xeres.ui.client.ProfileClient;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Answers;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport reactor.core.publisher.Mono;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static reactor.core.publisher.Mono.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass AccountCreationWindowControllerTest\n{\n\t@Mock(answer = Answers.RETURNS_DEEP_STUBS)\n\tprivate ConfigClient configClient;\n\n\t@Mock\n\tprivate ProfileClient profileClient;\n\n\t@Mock\n\tprivate WindowManager windowManager;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate AccountCreationWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(AccountCreationWindowControllerTest.class.getResource(\"/view/account/account_creation.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\twhen(configClient.getUsername()).thenReturn(Mono.just(new UsernameResponse(\"username\")));\n\t\twhen(configClient.getHostname()).thenReturn(Mono.just(new HostnameResponse(\"hostname\")));\n\t\twhen(configClient.createProfile(anyString())).thenReturn(Mono.just(Void.class));\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\n\t\tcontroller.generateProfileAndLocation(\"foo\", \"bar\");\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/chat/ChatRoomCreationWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.ChatClient;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass ChatRoomCreationWindowControllerTest\n{\n\t@Mock\n\tprivate ChatClient chatClient;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate ChatRoomCreationWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(ChatRoomCreationWindowControllerTest.class.getResource(\"/view/chat/chatroom_create.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/chat/ChatRoomInvitationWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.ChatClient;\nimport io.xeres.ui.client.ConnectionClient;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport reactor.core.publisher.Flux;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass ChatRoomInvitationWindowControllerTest\n{\n\t@Mock\n\tprivate ConnectionClient connectionClient;\n\n\t@Mock\n\tprivate ChatClient chatClient;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate ChatRoomInvitationWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(ChatRoomInvitationWindowControllerTest.class.getResource(\"/view/chat/chatroom_invite.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\twhen(connectionClient.getConnectedProfiles()).thenReturn(Flux.empty());\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/chat/ChatViewControllerTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.chat;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.common.message.chat.ChatRoomContext;\nimport io.xeres.common.message.chat.ChatRoomInfo;\nimport io.xeres.common.message.chat.ChatRoomLists;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.sound.SoundPlayerService;\nimport io.xeres.ui.support.tray.TrayService;\nimport io.xeres.ui.support.uri.UriService;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.mockito.junit.jupiter.MockitoSettings;\nimport org.mockito.quality.Strictness;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\n@MockitoSettings(strictness = Strictness.LENIENT)\nclass ChatViewControllerTest\n{\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate MessageClient messageClient;\n\n\t@Mock\n\tprivate ChatClient chatClient;\n\n\t@Mock\n\tprivate ProfileClient profileClient;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate LocationClient locationClient;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate WindowManager windowManager;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate TrayService trayService;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate MarkdownService markdownService;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate UriService uriService;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate GeneralClient generalClient;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate ImageCache imageCache;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate SoundPlayerService soundPlayerService;\n\n\t@SuppressWarnings(\"unused\")\n\t@Mock\n\tprivate ShareClient shareClient;\n\n\t@Mock\n\tprivate NotificationClient notificationClient;\n\n\t@InjectMocks\n\tprivate ChatViewController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(ChatViewControllerTest.class.getResource(\"/view/chat/chat_view.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tvar ownProfile = new Profile();\n\t\townProfile.setName(\"foobar\");\n\n\t\tvar location = new Location();\n\t\tlocation.setName(\"Foobar location\");\n\t\tlocation.setLocationIdentifier(IdFakes.createLocationIdentifier());\n\n\t\tPreferenceUtils.setLocation(location);\n\n\t\tvar chatRoomList = new ChatRoomLists();\n\t\tchatRoomList.addAvailable(new ChatRoomInfo(\"availableRoom\"));\n\t\tchatRoomList.addSubscribed(new ChatRoomInfo(\"subscribedRoom\"));\n\t\tvar chatRoomUser = new io.xeres.common.message.chat.ChatRoomUser(\"foobar\", null, 1L);\n\t\tvar chatRoomContext = new ChatRoomContext(chatRoomList, chatRoomUser);\n\n\t\twhen(profileClient.getOwn()).thenReturn(Mono.just(ownProfile));\n\t\twhen(chatClient.getChatRoomContext()).thenReturn(Mono.just(chatRoomContext));\n\t\twhen(notificationClient.getContactNotifications()).thenReturn(Flux.empty());\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/contact/ContactViewControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.contact;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.model.location.Location;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.support.preference.PreferenceUtils;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass ContactViewControllerTest\n{\n\t@Mock\n\tprivate ContactClient contactClient;\n\n\t@Mock\n\tprivate GeneralClient generalClient;\n\n\t@Mock\n\tprivate ProfileClient profileClient;\n\n\t@Mock\n\tprivate IdentityClient identityClient;\n\n\t@Mock\n\tprivate NotificationClient notificationClient;\n\n\t@Mock\n\tprivate ImageCache imageCacheService;\n\n\t@Mock\n\tprivate WindowManager windowManager;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate ContactViewController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(ContactViewControllerTest.class.getResource(\"/view/contact/contact_view.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tvar ownProfile = new Profile();\n\t\townProfile.setName(\"foobar\");\n\n\t\tvar location = new Location();\n\t\tlocation.setName(\"Foobar location\");\n\t\tlocation.setLocationIdentifier(IdFakes.createLocationIdentifier());\n\n\t\tPreferenceUtils.setLocation(location);\n\n\t\twhen(profileClient.getOwn()).thenReturn(Mono.just(ownProfile));\n\t\twhen(notificationClient.getContactNotifications()).thenReturn(Flux.empty());\n\t\twhen(notificationClient.getAvailabilityNotifications()).thenReturn(Flux.empty());\n\t\twhen(contactClient.getContacts()).thenReturn(Flux.empty());\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/help/HelpWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.help;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.uri.UriService;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.core.io.Resource;\nimport org.springframework.core.io.support.ResourcePatternResolver;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass HelpWindowControllerTest\n{\n\t@Mock\n\tprivate MarkdownService markdownService;\n\n\t@Mock\n\tprivate ResourcePatternResolver resourcePatternResolver;\n\n\t@Mock\n\tprivate UriService uriService;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate HelpWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(HelpWindowControllerTest.class.getResource(\"/view/help/help.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\twhen(resourcePatternResolver.getResources(anyString())).thenReturn(new Resource[]{});\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/help/NavigatorTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.help;\n\nimport io.xeres.ui.support.uri.ExternalUri;\nimport io.xeres.ui.support.uri.Uri;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass NavigatorTest\n{\n\t@Test\n\tvoid NavigateScenario()\n\t{\n\t\tList<Uri> locations = new ArrayList<>();\n\n\t\tvar navigator = new Navigator(locations::add);\n\n\t\tassertFalse(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\n\t\tnavigator.navigate(new ExternalUri(\"first.md\"));\n\n\t\tassertFalse(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\n\t\tnavigator.navigate(new ExternalUri(\"second.md\"));\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\n\t\tnavigator.navigate(new ExternalUri(\"third.md\"));\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\t\tassertEquals(3, locations.size());\n\t\tassertEquals(\"first.md\", locations.get(0).toString());\n\t\tassertEquals(\"second.md\", locations.get(1).toString());\n\t\tassertEquals(\"third.md\", locations.get(2).toString());\n\n\t\tnavigator.navigateBackwards();\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertTrue(navigator.forwardProperty.get());\n\t\tassertEquals(4, locations.size());\n\t\tassertEquals(\"second.md\", locations.getLast().toString());\n\n\t\tnavigator.navigateBackwards();\n\n\t\tassertFalse(navigator.backProperty.get());\n\t\tassertTrue(navigator.forwardProperty.get());\n\t\tassertEquals(5, locations.size());\n\t\tassertEquals(\"first.md\", locations.getLast().toString());\n\n\t\tnavigator.navigateBackwards();\n\n\t\t// No changes\n\t\tassertFalse(navigator.backProperty.get());\n\t\tassertTrue(navigator.forwardProperty.get());\n\t\tassertEquals(5, locations.size());\n\t\tassertEquals(\"first.md\", locations.getLast().toString());\n\n\t\tnavigator.navigateForwards();\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertTrue(navigator.forwardProperty.get());\n\t\tassertEquals(6, locations.size());\n\t\tassertEquals(\"second.md\", locations.getLast().toString());\n\n\t\tnavigator.navigateForwards();\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\t\tassertEquals(7, locations.size());\n\t\tassertEquals(\"third.md\", locations.getLast().toString());\n\n\t\tnavigator.navigateBackwards();\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertTrue(navigator.forwardProperty.get());\n\t\tassertEquals(8, locations.size());\n\t\tassertEquals(\"second.md\", locations.getLast().toString());\n\n\t\tnavigator.navigate(new ExternalUri(\"fourth.md\"));\n\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\t\tassertEquals(9, locations.size());\n\t\tassertEquals(\"fourth.md\", locations.getLast().toString());\n\n\t\tnavigator.navigateForwards();\n\n\t\t// No changes\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\t\tassertEquals(9, locations.size());\n\t\tassertEquals(\"fourth.md\", locations.getLast().toString());\n\n\t\tnavigator.navigate(new ExternalUri(\"fourth.md\"));\n\n\t\t// No changes\n\t\tassertTrue(navigator.backProperty.get());\n\t\tassertFalse(navigator.forwardProperty.get());\n\t\tassertEquals(9, locations.size());\n\t\tassertEquals(\"fourth.md\", locations.getLast().toString());\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/id/AddRsIdWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.id;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.GeoIpClient;\nimport io.xeres.ui.client.ProfileClient;\nimport io.xeres.ui.model.profile.Profile;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Answers;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport reactor.core.publisher.Mono;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static reactor.core.publisher.Mono.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass AddRsIdWindowControllerTest\n{\n\t@Mock(answer = Answers.RETURNS_DEEP_STUBS)\n\tprivate ProfileClient profileClient;\n\n\t@Mock(answer = Answers.RETURNS_DEEP_STUBS)\n\tprivate GeoIpClient geoIpClient;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@Mock\n\tprivate WindowManager windowManager;\n\n\t@InjectMocks\n\tprivate AddRsIdWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(AddRsIdWindowControllerTest.class.getResource(\"/view/id/rsid_add.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tvar ownProfile = new Profile();\n\n\t\twhen(profileClient.getOwn()).thenReturn(Mono.just(ownProfile));\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\n\t\tcontroller.setRsId(\"foo\");\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/messaging/BroadcastWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.messaging;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.message.MessageClient;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass BroadcastWindowControllerTest\n{\n\t@Mock\n\tprivate MessageClient messageClient;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate BroadcastWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(BroadcastWindowControllerTest.class.getResource(\"/view/messaging/broadcast.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/messaging/MessagingWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.messaging;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.ui.client.*;\nimport io.xeres.ui.client.message.MessageClient;\nimport io.xeres.ui.custom.asyncimage.ImageCache;\nimport io.xeres.ui.support.markdown.MarkdownService;\nimport io.xeres.ui.support.uri.UriService;\nimport io.xeres.ui.support.window.WindowManager;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.MockitoAnnotations;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass MessagingWindowControllerTest\n{\n\t@Mock\n\tprivate ProfileClient profileClient;\n\n\t@Mock\n\tprivate IdentityClient identityClient;\n\n\t@Mock\n\tprivate MarkdownService markdownService;\n\n\t@Mock\n\tprivate WindowManager windowManager;\n\n\t@Mock\n\tprivate UriService uriService;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@Mock\n\tprivate MessageClient messageClient;\n\n\t@Mock\n\tprivate ShareClient shareClient;\n\n\t@Mock\n\tprivate ChatClient chatClient;\n\n\t@Mock\n\tprivate GeneralClient generalClient;\n\n\t@Mock\n\tprivate LocationClient locationClient;\n\n\t@Mock\n\tprivate ImageCache imageCache;\n\n\tprivate AutoCloseable closeable;\n\n\t@BeforeEach\n\tvoid setUp()\n\t{\n\t\tcloseable = MockitoAnnotations.openMocks(this);\n\t}\n\n\t@AfterEach\n\tvoid tearDown() throws Exception\n\t{\n\t\tcloseable.close();\n\t}\n\n\t@Test\n\tvoid testFxmlLoading() throws Exception\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(MessagingWindowControllerTest.class.getResource(\"/view/messaging/messaging.fxml\"), resourceBundle);\n\n\t\tvar controller = new MessagingWindowController(profileClient, identityClient, windowManager, uriService, messageClient, shareClient, markdownService, IdFakes.createLocationIdentifier(), resourceBundle, chatClient, generalClient, null, imageCache, locationClient, false);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/qrcode/QrCodeWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.qrcode;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.GeneralClient;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport java.io.IOException;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass QrCodeWindowControllerTest\n{\n\t@Mock\n\tprivate GeneralClient generalClient;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate QrCodeWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(QrCodeWindowControllerTest.class.getResource(\"/view/qrcode/qrcode.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}\n\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/controller/share/ShareWindowControllerTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.controller.share;\n\nimport io.xeres.common.i18n.I18nUtils;\nimport io.xeres.ui.client.ShareClient;\nimport io.xeres.ui.model.share.Share;\nimport javafx.fxml.FXMLLoader;\nimport javafx.scene.Parent;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Answers;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Spy;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport reactor.core.publisher.Flux;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.ResourceBundle;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static reactor.core.publisher.Mono.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass ShareWindowControllerTest\n{\n\t@Mock(answer = Answers.RETURNS_DEEP_STUBS)\n\tprivate ShareClient shareClient;\n\n\t@Spy\n\tprivate final ResourceBundle resourceBundle = I18nUtils.getBundle();\n\n\t@InjectMocks\n\tprivate ShareWindowController controller;\n\n\t@Test\n\tvoid testFxmlLoading() throws IOException\n\t{\n\t\tFXMLLoader loader = new FXMLLoader(ShareWindowControllerTest.class.getResource(\"/view/file/share.fxml\"), resourceBundle);\n\n\t\tloader.setControllerFactory(_ -> controller);\n\n\t\tvar share = new Share();\n\t\tshare.setName(\"test\");\n\n\t\twhen(shareClient.findAll()).thenReturn(Flux.just(List.of(share)));\n\n\t\tParent root = loader.load();\n\n\t\tassertNotNull(root);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/custom/AsyncImageViewTest.java",
    "content": "/*\n * Copyright (c) 2024-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport io.xeres.ui.client.GeneralClient;\nimport io.xeres.ui.custom.asyncimage.AsyncImageView;\nimport javafx.scene.Scene;\nimport javafx.scene.layout.VBox;\nimport javafx.stage.Stage;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport org.testfx.framework.junit5.Start;\nimport reactor.core.publisher.Mono;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.Objects;\n\nimport static org.awaitility.Awaitility.await;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith({ApplicationExtension.class, MockitoExtension.class})\nclass AsyncImageViewTest\n{\n\tprivate AsyncImageView asyncImageView;\n\n\t@Start\n\tprivate void start(Stage stage)\n\t{\n\t\tasyncImageView = new AsyncImageView(url -> generalClient.getImage(url).block());\n\t\tstage.setScene(new Scene(new VBox(asyncImageView), 256, 256));\n\t\tstage.show();\n\t}\n\n\t@Mock\n\tprivate GeneralClient generalClient;\n\n\t@Test\n\tvoid setUrl_Success() throws IOException\n\t{\n\t\tvar url = \"/foo/bar.jpg\";\n\t\tvar data = Objects.requireNonNull(AsyncImageViewTest.class.getResourceAsStream(\"/image/icon.png\")).readAllBytes();\n\n\t\twhen(generalClient.getImage(url)).thenReturn(Mono.just(data));\n\n\t\tasyncImageView.setUrl(url);\n\n\t\tawait().atMost(Duration.ofSeconds(1)).until(() -> asyncImageView.getImage() != null);\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/custom/EditorViewTest.java",
    "content": "/*\n * Copyright (c) 2023-2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.custom;\n\nimport javafx.scene.Scene;\nimport javafx.scene.input.KeyCode;\nimport javafx.scene.input.KeyCodeCombination;\nimport javafx.scene.input.KeyCombination;\nimport javafx.scene.layout.VBox;\nimport javafx.stage.Stage;\nimport org.junit.jupiter.api.MethodOrderer;\nimport org.junit.jupiter.api.Order;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestMethodOrder;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.testfx.api.FxRobot;\nimport org.testfx.framework.junit5.ApplicationExtension;\nimport org.testfx.framework.junit5.Start;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\n@ExtendWith(ApplicationExtension.class)\n@TestMethodOrder(MethodOrderer.OrderAnnotation.class)\nclass EditorViewTest\n{\n\tprivate EditorView editorView;\n\n\t@Start\n\tprivate void start(Stage stage)\n\t{\n\t\teditorView = new EditorView();\n\t\teditorView.setId(\"editorView\");\n\t\tstage.setScene(new Scene(new VBox(editorView), 640, 480));\n\t\tstage.show();\n\t}\n\n\t@Test\n\t@Order(1)\n\t\t// this method must be first, possibly related to the above workaround\n\tvoid Content_Empty()\n\t{\n\t\tassertEquals(\"\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Type_Echoed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello, world\");\n\t\tassertEquals(\"hello, world\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Bold_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#bold\");\n\t\tassertEquals(\"****\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Bold_Transformed_ShortCut(FxRobot robot)\n\t{\n\t\trobot.push(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN));\n\t\tassertEquals(\"****\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Bold_Selected_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#bold\");\n\t\tassertEquals(\"**hello**\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Italic_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#italic\");\n\t\tassertEquals(\"__\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Italic_Selected_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#italic\");\n\t\tassertEquals(\"_hello_\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Code_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#code\");\n\t\tassertEquals(\"```\\n\\n```\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Code_Selected_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#code\");\n\t\tassertEquals(\"```\\nhello\\n```\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Quote_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#quote\");\n\t\tassertEquals(\"> \", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Quote_Selected_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#quote\");\n\t\tassertEquals(\"> hello\\n\\n\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Quote_Selected_Multiples_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\\nworld\\nhere\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#quote\");\n\t\tassertEquals(\"> hello\\n> world\\n> here\\n\\n\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_List_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#unorderedList\");\n\t\tassertEquals(\"- \", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_List_Selected_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#unorderedList\");\n\t\tassertEquals(\"\\n- hello\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_List_Ordered_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#orderedList\");\n\t\tassertEquals(\"1. \", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Heading_Transformed(FxRobot robot)\n\t{\n\t\trobot.clickOn(\"#heading\");\n\t\trobot.clickOn(\"#header2\");\n\t\tassertEquals(\"## \", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Heading_Selected_Transformed(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.press(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.release(KeyCode.CONTROL, KeyCode.A);\n\t\trobot.clickOn(\"#heading\");\n\t\trobot.clickOn(\"#header2\");\n\t\tassertEquals(\"\\n## hello\", editorView.getText());\n\t}\n\n\t@Test\n\tvoid Content_Undo_Redo(FxRobot robot)\n\t{\n\t\trobot.write(\"hello\");\n\t\trobot.clickOn(\"#undo\");\n\t\tassertEquals(\"\", editorView.getText());\n\t\trobot.clickOn(\"#redo\");\n\t\tassertEquals(\"hello\", editorView.getText());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/chat/ChatMapperTest.java",
    "content": "/*\n * Copyright (c) 2023-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.chat;\n\nimport io.xeres.common.dto.chat.ChatRoomContextDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ChatMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ChatMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_ChatRoomContext_Success()\n\t{\n\t\tvar dto = ChatRoomContextDTOFakes.createChatRoomContextDTO();\n\n\t\tvar chatRoomContext = ChatMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.chatRooms().available().size(), chatRoomContext.chatRoomLists().getAvailableRooms().size());\n\t\tassertEquals(dto.chatRooms().subscribed().size(), chatRoomContext.chatRoomLists().getSubscribedRooms().size());\n\n\t\tvar from = dto.chatRooms().available().getFirst();\n\t\tvar to = chatRoomContext.chatRoomLists().getAvailableRooms().getFirst();\n\n\t\tassertEquals(from.name(), to.getName());\n\t\tassertEquals(from.id(), to.getId());\n\t\tassertEquals(from.count(), to.getCount());\n\t\tassertEquals(from.roomType(), to.getRoomType());\n\t\tassertEquals(from.topic(), to.getTopic());\n\t\tassertEquals(from.isSigned(), to.isSigned());\n\n\t\tassertEquals(chatRoomContext.ownUser().nickname(), dto.identity().nickname());\n\t\tassertEquals(chatRoomContext.ownUser().gxsId(), dto.identity().gxsId());\n\t\tassertEquals(chatRoomContext.ownUser().identityId(), dto.identity().identityId());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/connection/ConnectionMapperTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.connection;\n\nimport io.xeres.common.dto.connection.ConnectionDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ConnectionMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ConnectionMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_Success()\n\t{\n\t\tvar dto = ConnectionDTOFakes.createConnectionDTO();\n\n\t\tvar connection = ConnectionMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.id(), connection.getId());\n\t\tassertEquals(dto.address(), connection.getAddress());\n\t\tassertEquals(dto.lastConnected(), connection.getLastConnected());\n\t\tassertEquals(dto.external(), connection.isExternal());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/identity/IdentityMapperTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.identity;\n\nimport io.xeres.common.dto.identity.IdentityDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass IdentityMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(IdentityMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_Success()\n\t{\n\t\tvar dto = IdentityDTOFakes.createIdentityDTO();\n\n\t\tvar identity = IdentityMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.id(), identity.getId());\n\t\tassertEquals(dto.name(), identity.getName());\n\t\tassertEquals(dto.gxsId(), identity.getGxsId());\n\t\tassertEquals(dto.type(), identity.getType());\n\t\tassertEquals(dto.hasImage(), identity.hasImage());\n\t\tassertEquals(dto.updated(), identity.getUpdated());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/location/LocationMapperTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.location;\n\nimport io.xeres.common.dto.location.LocationDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass LocationMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(LocationMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_Success()\n\t{\n\t\tvar dto = LocationDTOFakes.create();\n\n\t\tvar location = LocationMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.id(), location.getId());\n\t\tassertArrayEquals(dto.locationIdentifier(), location.getLocationIdentifier().getBytes());\n\t\tassertEquals(dto.name(), location.getName());\n\t\tassertEquals(dto.connected(), location.isConnected());\n\t\tassertEquals(dto.lastConnected(), location.getLastConnected());\n\t}\n\n\t@Test\n\tvoid FromDeepDTO_Success()\n\t{\n\t\tvar dto = LocationDTOFakes.create();\n\n\t\tvar location = LocationMapper.fromDeepDTO(dto);\n\n\t\tassertEquals(dto.id(), location.getId());\n\t\tassertArrayEquals(dto.locationIdentifier(), location.getLocationIdentifier().getBytes());\n\t\tassertEquals(dto.name(), location.getName());\n\t\t//assertEquals(dto.hostname(), location.getHostname()); XXX\n\t\tassertEquals(dto.connected(), location.isConnected());\n\t\tassertEquals(dto.lastConnected(), location.getLastConnected());\n\t\tassertEquals(dto.connections().size(), location.getConnections().size());\n\t\tassertEquals(dto.connections().getFirst().id(), location.getConnections().getFirst().getId());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/profile/ProfileMapperTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.profile;\n\nimport io.xeres.common.dto.profile.ProfileDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ProfileMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ProfileMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_Success()\n\t{\n\t\tvar dto = ProfileDTOFakes.create();\n\n\t\tvar profile = ProfileMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.id(), profile.getId());\n\t\tassertEquals(dto.name(), profile.getName());\n\t\tassertEquals(Long.parseLong(dto.pgpIdentifier()), profile.getPgpIdentifier());\n\t\tassertArrayEquals(dto.pgpFingerprint(), profile.getProfileFingerprint().getBytes());\n\t\tassertArrayEquals(dto.pgpPublicKeyData(), profile.getPgpPublicKeyData());\n\t\tassertEquals(dto.accepted(), profile.isAccepted());\n\t\tassertEquals(dto.trust(), profile.getTrust());\n\t}\n\n\t@Test\n\tvoid FromDeepDTO_Success()\n\t{\n\t\tvar dto = ProfileDTOFakes.create();\n\n\t\tvar profile = ProfileMapper.fromDeepDTO(dto);\n\n\t\tassertEquals(dto.id(), profile.getId());\n\t\tassertEquals(dto.name(), profile.getName());\n\t\tassertEquals(Long.parseLong(dto.pgpIdentifier()), profile.getPgpIdentifier());\n\t\tassertArrayEquals(dto.pgpFingerprint(), profile.getProfileFingerprint().getBytes());\n\t\tassertArrayEquals(dto.pgpPublicKeyData(), profile.getPgpPublicKeyData());\n\t\tassertEquals(dto.accepted(), profile.isAccepted());\n\t\tassertEquals(dto.trust(), profile.getTrust());\n\t\tassertEquals(dto.locations().size(), profile.getLocations().size());\n\t\tassertEquals(dto.locations().getFirst().id(), profile.getLocations().getFirst().getId());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/settings/SettingsMapperTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.settings;\n\nimport io.xeres.common.dto.settings.SettingsDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass SettingsMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(SettingsMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_Success()\n\t{\n\t\tvar dto = SettingsDTOFakes.create();\n\n\t\tvar settings = SettingsMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.torSocksHost(), settings.getTorSocksHost());\n\t\tassertEquals(dto.torSocksPort(), settings.getTorSocksPort());\n\t\tassertEquals(dto.i2pSocksHost(), settings.getI2pSocksHost());\n\t\tassertEquals(dto.i2pSocksPort(), settings.getI2pSocksPort());\n\t\tassertEquals(dto.dhtEnabled(), settings.isDhtEnabled());\n\t\tassertEquals(dto.upnpEnabled(), settings.isUpnpEnabled());\n\t\tassertEquals(dto.autoStartEnabled(), settings.isAutoStartEnabled());\n\t\tassertEquals(dto.broadcastDiscoveryEnabled(), settings.isBroadcastDiscoveryEnabled());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/model/share/ShareMapperTest.java",
    "content": "/*\n * Copyright (c) 2024 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.model.share;\n\nimport io.xeres.common.dto.share.ShareDTOFakes;\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass ShareMapperTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(ShareMapper.class);\n\t}\n\n\t@Test\n\tvoid FromDTO_Success()\n\t{\n\t\tvar dto = ShareDTOFakes.createShareDTO();\n\n\t\tvar share = ShareMapper.fromDTO(dto);\n\n\t\tassertEquals(dto.id(), share.getId());\n\t\tassertEquals(dto.name(), share.getName());\n\t\tassertEquals(dto.path(), share.getPath());\n\t\tassertEquals(dto.searchable(), share.isSearchable());\n\t\tassertEquals(dto.browsable(), share.getBrowsable());\n\t\tassertEquals(dto.lastScanned(), share.getLastScanned());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/chat/ChatActionTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.testutils.StringFakes;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ChatActionTest\n{\n\t@Test\n\tvoid HasMessageLine_Success()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar nickname = StringFakes.createNickname();\n\t\tvar action = new ChatAction(ChatAction.Type.JOIN, nickname, gxsId);\n\n\t\tassertTrue(action.isPresenceEvent());\n\t\tassertEquals(nickname + \" (\" + gxsId + \")\", action.getPresenceLine());\n\t}\n\n\t@Test\n\tvoid HasMessageLine_None()\n\t{\n\t\tvar action = new ChatAction(ChatAction.Type.SAY, StringFakes.createNickname(), IdFakes.createGxsId());\n\n\t\tassertFalse(action.isPresenceEvent());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/chat/ChatParserTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ChatParserTest\n{\n\t@Test\n\tvoid ParseActionMe_Success()\n\t{\n\t\tvar nickname = \"foobar\";\n\t\tvar input = \"/me is hungry\";\n\n\t\tvar result = ChatParser.parseActionMe(input, nickname);\n\n\t\tassertEquals(nickname + \" is hungry\", result);\n\t}\n\n\t@Test\n\tvoid IsActionMe_Success()\n\t{\n\t\tassertTrue(ChatParser.isActionMe(\"/me is happy\"));\n\t\tassertFalse(ChatParser.isActionMe(\"and wants to use Xeres\"));\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/chat/ColorGeneratorTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass ColorGeneratorTest\n{\n\t@Test\n\tvoid GenerateColor_Success()\n\t{\n\t\tvar color = ColorGenerator.generateColor(\"abc\");\n\t\tassertEquals(\"color-02\", color);\n\t}\n\n\t@Test\n\tvoid GenerateColor_Failure()\n\t{\n\t\tassertThrows(NullPointerException.class, () -> ColorGenerator.generateColor(null));\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/chat/NicknameCompleterTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.chat;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.Mockito;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.function.Consumer;\n\nimport static org.mockito.Mockito.*;\n\n@SuppressWarnings(\"unchecked\")\n@ExtendWith(MockitoExtension.class)\nclass NicknameCompleterTest\n{\n\t@Mock\n\tprivate NicknameCompleter.UsernameFinder usernameFinder;\n\n\t@InjectMocks\n\tprivate NicknameCompleter nicknameCompleter;\n\n\t@Test\n\tvoid Complete_Empty_Start()\n\t{\n\t\tConsumer<String> action = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"\", 0)).thenReturn(null);\n\n\t\tnicknameCompleter.complete(\"\", 0, action);\n\t\tverify(action, never()).accept(\"\");\n\t}\n\n\t@Test\n\tvoid Complete_Empty()\n\t{\n\t\tConsumer<String> action = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"\", 0)).thenReturn(null);\n\n\t\tnicknameCompleter.complete(\"Hello \", 6, action);\n\t\tverify(action, never()).accept(\"\");\n\t}\n\n\t@Test\n\tvoid Complete_Single_Start()\n\t{\n\t\tConsumer<String> action = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"\", 0)).thenReturn(\"Nicolas\");\n\n\t\tnicknameCompleter.complete(\"\", 0, action);\n\n\t\tverify(action).accept(\"Nicolas: \");\n\t}\n\n\t@Test\n\tvoid Complete_Multiple_Start()\n\t{\n\t\tConsumer<String> action1 = Mockito.mock(Consumer.class);\n\t\tConsumer<String> action2 = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"\", 0)).thenReturn(\"Alceste\");\n\t\twhen(usernameFinder.getUsername(\"\", 1)).thenReturn(\"Nicolas\");\n\n\t\tnicknameCompleter.complete(\"\", 0, action1);\n\t\tnicknameCompleter.complete(\"Alceste: \", 9, action2);\n\n\t\tverify(action1).accept(\"Alceste: \");\n\t\tverify(action2).accept(\"Nicolas: \");\n\t}\n\n\t@Test\n\tvoid Complete_MultipleWithPrefix_Start()\n\t{\n\t\tConsumer<String> action1 = Mockito.mock(Consumer.class);\n\t\tConsumer<String> action2 = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"A\", 0)).thenReturn(\"Agnan\");\n\t\twhen(usernameFinder.getUsername(\"A\", 1)).thenReturn(\"Alceste\");\n\n\t\tnicknameCompleter.complete(\"A\", 1, action1);\n\t\tnicknameCompleter.complete(\"Agnan: \", 7, action2);\n\n\t\tverify(action1).accept(\"Agnan: \");\n\t\tverify(action2).accept(\"Alceste: \");\n\t}\n\n\t@Test\n\tvoid Complete_Single()\n\t{\n\t\tConsumer<String> action = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"\", 0)).thenReturn(\"Nicolas\");\n\n\t\tnicknameCompleter.complete(\"This is some text for \", 22, action);\n\n\t\tverify(action).accept(\"This is some text for Nicolas\");\n\t}\n\n\t@Test\n\tvoid Complete_Multiple()\n\t{\n\t\tConsumer<String> action1 = Mockito.mock(Consumer.class);\n\t\tConsumer<String> action2 = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"\", 0)).thenReturn(\"Alceste\");\n\t\twhen(usernameFinder.getUsername(\"\", 1)).thenReturn(\"Nicolas\");\n\n\t\tnicknameCompleter.complete(\"This is some text for \", 22, action1);\n\t\tnicknameCompleter.complete(\"This is some text for Alceste\", 29, action2);\n\n\t\tverify(action1).accept(\"This is some text for Alceste\");\n\t\tverify(action2).accept(\"This is some text for Nicolas\");\n\t}\n\n\t@Test\n\tvoid Complete_MultipleWithPrefix()\n\t{\n\t\tConsumer<String> action1 = Mockito.mock(Consumer.class);\n\t\tConsumer<String> action2 = Mockito.mock(Consumer.class);\n\n\t\twhen(usernameFinder.getUsername(\"A\", 0)).thenReturn(\"Agnan\");\n\t\twhen(usernameFinder.getUsername(\"A\", 1)).thenReturn(\"Alceste\");\n\n\t\tnicknameCompleter.complete(\"This is some text for A\", 23, action1);\n\t\tnicknameCompleter.complete(\"This is some text for Agnan\", 27, action2);\n\n\t\tverify(action1).accept(\"This is some text for Agnan\");\n\t\tverify(action2).accept(\"This is some text for Alceste\");\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/emoji/EmojiServiceTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.emoji;\n\nimport io.xeres.ui.properties.UiClientProperties;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport tools.jackson.databind.json.JsonMapper;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\nclass EmojiServiceTest\n{\n\t@Mock\n\tprivate UiClientProperties uiClientProperties;\n\n\t// We cannot use @InjectMocks because EmojiService performs\n\t// computations that requires mocks in the constructor and that\n\t// is executed before \"when\" statements can be done.\n\tprivate EmojiService createEmojiService()\n\t{\n\t\treturn new EmojiService(uiClientProperties, new JsonMapper());\n\t}\n\n\n\t@ParameterizedTest\n\t@CsvSource({\n\t\t\t\"\\uD83E\\uDD71, 1f971\",\n\t\t\t\"\\uD83C\\uDDE6\\uD83C\\uDDE8, 1f1e6-1f1e8\",\n\t\t\t\"\\uD83D\\uDEA3\\uD83C\\uDFFD\\u200D\\u2640\\uFE0F, 1f6a3-1f3fd-200d-2640-fe0f\"\n\t})\n\tvoid CodeDecimalToUnicode_Success(String input, String expected)\n\t{\n\t\tvar emojiService = createEmojiService();\n\t\tvar result = emojiService.emojiToFileName(input);\n\n\t\tassertEquals(expected, result);\n\t}\n\n\t@ParameterizedTest\n\t@CsvSource({\n\t\t\t\"hello, hello\",\n\t\t\t\";-), 😉\",\n\t\t\t\":wink:, 😉\",\n\t\t\t\":wink: :wink, 😉 :wink\",\n\t\t\t\":wink :wink:, :wink 😉\"\n\t})\n\tvoid ToUnicode_Success(String input, String expected)\n\t{\n\t\twhen(uiClientProperties.isSmileyToUnicode()).thenReturn(true);\n\t\twhen(uiClientProperties.isRsEmojisAliases()).thenReturn(true);\n\n\t\tvar emojiService = createEmojiService();\n\n\t\tvar result = emojiService.toUnicode(input);\n\n\t\tassertEquals(expected, result);\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/markdown/MarkdownServiceTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.markdown;\n\nimport io.xeres.ui.FXTest;\nimport io.xeres.ui.custom.DisclosedHyperlink;\nimport io.xeres.ui.support.contentline.*;\nimport io.xeres.ui.support.emoji.EmojiService;\nimport io.xeres.ui.support.markdown.MarkdownService.Rendering;\nimport javafx.scene.text.Text;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInstance;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.mockito.Mockito;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.EnumSet;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith({MockitoExtension.class})\n@TestInstance(TestInstance.Lifecycle.PER_CLASS)\nclass MarkdownServiceTest extends FXTest\n{\n\tprivate final EmojiService emojiService = Mockito.mock(EmojiService.class);\n\n\t// We cannot use @InjectMocks because MarkdownService performs\n\t// computations that requires mocks in the constructor and that\n\t// is executed before \"when\" statements can be done.\n\tprivate MarkdownService createMarkdownService()\n\t{\n\t\treturn new MarkdownService(emojiService, null);\n\t}\n\n\t@BeforeAll\n\tvoid configureMock()\n\t{\n\t\twhen(emojiService.isColoredEmojis()).thenReturn(true);\n\t\twhen(emojiService.toUnicode(anyString())).thenAnswer(invocation -> invocation.getArgument(0));\n\t}\n\n\t@Test\n\tvoid Parse_Sanitize_Default_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar text = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\tLine2 with trails  \\s\n\t\t\t\t\n\t\t\t\tLine3\n\t\t\t\tLine4\n\t\t\t\t\n\t\t\t\t\n\t\t\t\tLine 5\n\t\t\t\t\"\"\";\n\n\t\tvar wanted = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\tLine2 with trails\n\t\t\t\t\n\t\t\t\tLine3\n\t\t\t\tLine4\n\t\t\t\t\n\t\t\t\tLine 5\"\"\";\n\n\t\tassertEquals(wanted, markdownService.parse(text, EnumSet.noneOf(Rendering.class), null).stream()\n\t\t\t\t.map(Content::asText)\n\t\t\t\t.collect(Collectors.joining()));\n\t}\n\n\t@Test\n\tvoid Parse_Sanitize_Default_Verbatim_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar text = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t> Line2\n\t\t\t\t> Line3\n\t\t\t\t\"\"\";\n\n\t\tvar wanted = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\t> Line2\n\t\t\t\t> Line3\"\"\";\n\n\t\tassertEquals(wanted, markdownService.parse(text, EnumSet.of(Rendering.TEXT_REFLOW), null).stream()\n\t\t\t\t.map(Content::asText)\n\t\t\t\t.collect(Collectors.joining()));\n\t}\n\n\t@Test\n\tvoid Parse_Sanitize_Quoted_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar text = \"\"\"\n\t\t\t\t> Line1\n\t\t\t\t> Line2\n\t\t\t\t\n\t\t\t\tLine3\n\t\t\t\t\"\"\";\n\n\t\tvar wanted = \"\"\"\n\t\t\t\t> Line1\n\t\t\t\t> Line2\n\t\t\t\t\n\t\t\t\tLine3\"\"\";\n\n\t\tassertEquals(wanted, markdownService.parse(text, EnumSet.of(Rendering.TEXT_REFLOW), null).stream()\n\t\t\t\t.map(Content::asText)\n\t\t\t\t.collect(Collectors.joining()));\n\t}\n\n\t@Test\n\tvoid Sanitize_NoEndOfLine_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar text = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\tLine2 with trails  \\s\n\t\t\t\t\n\t\t\t\tLine3\n\t\t\t\tLine4\n\t\t\t\t\"\"\";\n\n\t\tvar wanted = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\tLine2 with trails\n\t\t\t\t\n\t\t\t\tLine3\n\t\t\t\tLine4\"\"\";\n\n\t\tassertEquals(wanted, markdownService.parse(text, EnumSet.noneOf(Rendering.class), null).stream()\n\t\t\t\t.map(Content::asText)\n\t\t\t\t.collect(Collectors.joining()));\n\t}\n\n\t@Test\n\tvoid Sanitize_Paragraph_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar text = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\tLine2 with trails  \\s\n\t\t\t\t\n\t\t\t\tLine3\n\t\t\t\tLine4\n\t\t\t\t\"\"\";\n\n\t\tvar wanted = \"\"\"\n\t\t\t\tLine1\n\t\t\t\t\n\t\t\t\tLine2 with trails\n\t\t\t\t\n\t\t\t\tLine3 Line4\"\"\";\n\n\t\tvar result = markdownService.parse(text, EnumSet.of(Rendering.TEXT_REFLOW), null).stream()\n\t\t\t\t.map(Content::asText)\n\t\t\t\t.collect(Collectors.joining());\n\n\t\tassertEquals(wanted, result);\n\t}\n\n\t@Test\n\tvoid ParseInlineUrls_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar input = \"Hello world! https://xeres.io is the site to visit now!\";\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(3, output.size());\n\t\tassertInstanceOf(ContentText.class, output.get(0));\n\t\tassertInstanceOf(ContentUri.class, output.get(1));\n\t\tassertInstanceOf(ContentText.class, output.get(2));\n\n\t\tassertEquals(\"Hello world! \", ((Text) output.get(0).getNode()).getText());\n\t\tassertEquals(\"https://xeres.io\", ((DisclosedHyperlink) output.get(1).getNode()).getText());\n\t\tassertEquals(\" is the site to visit now!\", ((Text) output.get(2).getNode()).getText());\n\t}\n\n\t@Test\n\tvoid ParseInlineUrls_WeirdChars_Success()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar input = \"https://www.foobar.com/watch?v=aXfS2p_ZyHY\";\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(1, output.size());\n\t\tassertInstanceOf(ContentUri.class, output.getFirst());\n\n\t\tassertEquals(input, ((DisclosedHyperlink) output.getFirst().getNode()).getText());\n\t}\n\n\t@ParameterizedTest\n\t@CsvSource({\n\t\t\t\"    foo();, foo();\",\n\t\t\t\"\\tfoo();, foo();\",\n\t\t\t\"        foo();,     foo();\"\n\t})\n\tvoid RemoveFirstStartingSpacesCode(String input, String expected)\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(expected, ((Text) output.getFirst().getNode()).getText());\n\t}\n\n\t@Test\n\tvoid Parse_Empty()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar input = \"\\n\";\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(0, output.size());\n\t}\n\n\t@Test\n\tvoid Parse_Empty_Too()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar input = \"\\n\\n\";\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(0, output.size());\n\t}\n\n\t@Test\n\tvoid Parse_Simple_Text()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar input = \"hello, world\\n\";\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(1, output.size());\n\n\t\tassertInstanceOf(ContentText.class, output.getFirst());\n\t\tassertEquals(\"hello, world\", ((Text) output.getFirst().getNode()).getText());\n\t}\n\n\t@Test\n\tvoid Parse_OneLine_Several()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar input = \"https://zapek.com !\\n\";\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(2, output.size());\n\n\t\tassertInstanceOf(ContentUri.class, output.get(0));\n\t\tassertEquals(\"https://zapek.com\", ((DisclosedHyperlink) output.get(0).getNode()).getText());\n\n\t\tassertInstanceOf(ContentText.class, output.get(1));\n\t\tassertEquals(\" !\", ((Text) output.get(1).getNode()).getText());\n\t}\n\n\t@Test\n\tvoid Parse_Multiline_Several()\n\t{\n\t\tvar markdownService = createMarkdownService();\n\n\t\tvar line1 = \"https://zapek.com :-) **yeah**\\n\";\n\t\tvar line2 = \"and another one: `fork();` it is\\n\";\n\t\tvar input = line1 + line2;\n\n\t\tvar output = markdownService.parse(input, EnumSet.noneOf(Rendering.class), null);\n\n\t\tassertEquals(7, output.size());\n\n\t\tassertInstanceOf(ContentUri.class, output.get(0));\n\t\tassertEquals(\"https://zapek.com\", ((DisclosedHyperlink) output.get(0).getNode()).getText());\n\n\t\tassertInstanceOf(ContentText.class, output.get(1));\n\t\tassertEquals(\" :-) \", ((Text) output.get(1).getNode()).getText());\n\n\t\tassertInstanceOf(ContentEmphasis.class, output.get(2));\n\t\tassertEquals(\"yeah\", ((Text) output.get(2).getNode()).getText());\n\t\tassertEquals(\"-fx-font-weight: bold;\", output.get(2).getNode().getStyle());\n\n\t\tassertInstanceOf(ContentText.class, output.get(3));\n\t\tassertEquals(\"\\n\", ((Text) output.get(3).getNode()).getText());\n\n\t\tassertInstanceOf(ContentText.class, output.get(4));\n\t\tassertEquals(\"and another one: \", ((Text) output.get(4).getNode()).getText());\n\n\t\tassertInstanceOf(ContentCode.class, output.get(5));\n\t\tassertEquals(\"fork();\", ((Text) output.get(5).getNode()).getText());\n\n\t\tassertInstanceOf(ContentText.class, output.get(6));\n\t\tassertEquals(\" it is\", ((Text) output.get(6).getNode()).getText());\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/uri/BoardUriFactoryTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.testutils.IdFakes;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport static io.xeres.ui.support.uri.UriFactoryUtils.createUriComponentsFromUri;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\n\n@ExtendWith(ApplicationExtension.class)\nclass BoardUriFactoryTest\n{\n\t@Test\n\tvoid BoardsUri_WrongParams_MissingGxsId_Fail()\n\t{\n\t\tvar url = \"retroshare://posted?name=test\";\n\n\t\tvar factory = new BoardUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertInstanceOf(ContentText.class, content);\n\t}\n\n\t@Test\n\tvoid BoardsUri_WrongParams_MissingName_Fail()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar url = \"retroshare://posted?id=\" + gxsId;\n\n\t\tvar factory = new BoardUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertInstanceOf(ContentText.class, content);\n\t}\n\n\t@Test\n\tvoid BoardsUri_TwoParams_Success()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\n\t\tvar url = \"retroshare://posted?name=test&id=\" + gxsId;\n\n\t\tvar factory = new BoardUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(url, ((ContentUri) content).getUri());\n\t}\n\n\t@Test\n\tvoid BoardsUri_ThreeParams_Success()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar msgId = IdFakes.createMsgId();\n\n\t\tvar url = \"retroshare://posted?name=test&id=\" + gxsId + \"&msgid=\" + msgId;\n\n\t\tvar factory = new BoardUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(url, ((ContentUri) content).getUri());\n\t}\n\n\t@Test\n\tvoid BoardsUri_Pretty()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar msgId = IdFakes.createMsgId();\n\n\t\tvar url = \"retroshare://posted?name=Fun%20Board&id=\" + gxsId + \"&msgid=\" + msgId;\n\n\t\tvar factory = new BoardUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(\"Fun Board\", content.asText());\n\t}\n\n\t@Test\n\tvoid BoardsUri_Pretty_FromText()\n\t{\n\t\tvar gxsId = IdFakes.createGxsId();\n\t\tvar msgId = IdFakes.createMsgId();\n\n\t\tvar url = \"retroshare://posted?name=Fun%20Board&id=\" + gxsId + \"&msgid=\" + msgId;\n\n\t\tvar factory = new BoardUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"Test\", _ -> {\n\t\t});\n\n\t\tassertEquals(\"Test\", content.asText());\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/uri/CertificateUriFactoryTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport static io.xeres.ui.support.uri.UriFactoryUtils.createUriComponentsFromUri;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\n\n@ExtendWith(ApplicationExtension.class)\nclass CertificateUriFactoryTest\n{\n\t@Test\n\tvoid CertificateUri_WrongParams_MissingRadix_Fail()\n\t{\n\t\tvar url = \"retroshare://certificate?name=foo\";\n\n\t\tvar factory = new CertificateUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertInstanceOf(ContentText.class, content);\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"retroshare://certificate?radix=abcd0123\",\n\t\t\t\"retroshare://certificate?radix=abcd0123&name=foo\",\n\t\t\t\"retroshare://certificate?radix=abcd0123&name=foo&location=earth\"\n\t})\n\tvoid CertificateUri_MultiParams_Success(String url)\n\t{\n\t\tvar factory = new CertificateUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(url, ((ContentUri) content).getUri());\n\t}\n\n\t@ParameterizedTest\n\t@CsvSource(delimiter = '|', value = {\n\t\t\t\"retroshare://certificate?radix=abcd0123| Xeres Certificate (unknown)\",\n\t\t\t\"retroshare://certificate?radix=abcd0123&name=foo| Xeres Certificate (foo)\",\n\t\t\t\"retroshare://certificate?radix=abcd0123&name=foo&location=earth| Xeres Certificate (foo, @earth)\"\n\t})\n\tvoid CertificateUri_Pretty(String url, String certificateName)\n\t{\n\t\tvar factory = new CertificateUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(certificateName, content.asText());\n\t}\n\n\t@Test\n\tvoid CertificateUri_Pretty_FromText()\n\t{\n\t\tvar url = \"retroshare://certificate?radix=abcd0123&name=foo&location=earth\";\n\n\t\tvar factory = new CertificateUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"Test\", _ -> {\n\t\t});\n\n\t\tassertEquals(\"Test\", content.asText());\n\t}\n\n\t@Test\n\tvoid CertificateUri_Generate()\n\t{\n\t\tassertEquals(\"<a href=\\\"retroshare://certificate?radix=1234&name=foo&location=bar\\\">Xeres Certificate (foo, @bar)</a>\", CertificateUriFactory.generate(\"1234\", \"foo\", \"bar\"));\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/uri/FileUriFactoryTest.java",
    "content": "/*\n * Copyright (c) 2025-2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport io.xeres.testutils.Sha1SumFakes;\nimport io.xeres.ui.support.contentline.ContentText;\nimport io.xeres.ui.support.contentline.ContentUri;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport static io.xeres.ui.support.uri.UriFactoryUtils.createUriComponentsFromUri;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\n\n@ExtendWith(ApplicationExtension.class)\nclass FileUriFactoryTest\n{\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"retroshare://file?size=128&hash=123400000000000000000000000000000000789a\", // missing name\n\t\t\t\"retroshare://file?name=foo&hash=123400000000000000000000000000000000789a\", // missing size\n\t\t\t\"retroshare://file?name=foo&size=128\" // missing hash\n\t})\n\tvoid FileUri_WrongParams_Fail(String url)\n\t{\n\t\tvar factory = new FileUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", null);\n\n\t\tassertInstanceOf(ContentText.class, content);\n\t}\n\n\t@Test\n\tvoid FileUri_Success()\n\t{\n\t\tvar url = \"retroshare://file?name=foo&size=128&hash=123400000000000000000000000000000000789a\";\n\n\t\tvar factory = new FileUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(url, ((ContentUri) content).getUri());\n\t}\n\n\t@Test\n\tvoid FileUri_Pretty()\n\t{\n\t\tvar url = \"retroshare://file?name=foo&size=128&hash=123400000000000000000000000000000000789a\";\n\n\t\tvar factory = new FileUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"\", _ -> {\n\t\t});\n\n\t\tassertEquals(\"foo (128 bytes)\", content.asText());\n\t}\n\n\t@Test\n\tvoid FileUri_Pretty_FromText()\n\t{\n\t\tvar url = \"retroshare://file?name=foo&size=128&hash=123400000000000000000000000000000000789a\";\n\n\t\tvar factory = new FileUriFactory();\n\t\tvar content = factory.createContent(createUriComponentsFromUri(url), \"Test\", _ -> {\n\t\t});\n\n\t\tassertEquals(\"Test\", content.asText());\n\t}\n\n\t@Test\n\tvoid FileUri_Generate_Success()\n\t{\n\t\tvar hash = Sha1SumFakes.createSha1Sum();\n\n\t\tvar result = FileUriFactory.generate(\"foo\", 128, hash);\n\n\t\tassertEquals(\"<a href=\\\"retroshare://file?name=foo&size=128&hash=\" + hash + \"\\\">foo (128 bytes)</a>\", result);\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/uri/UriFactoryUtils.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.uri;\n\nimport org.springframework.web.util.UriComponents;\nimport org.springframework.web.util.UriComponentsBuilder;\n\nimport java.net.URI;\nimport java.net.URISyntaxException;\n\npublic final class UriFactoryUtils\n{\n\tprivate UriFactoryUtils()\n\t{\n\t\tthrow new UnsupportedOperationException(\"Utility class\");\n\t}\n\n\tpublic static UriComponents createUriComponentsFromUri(String url)\n\t{\n\t\tURI uri;\n\t\ttry\n\t\t{\n\t\t\turi = new URI(url);\n\t\t}\n\t\tcatch (URISyntaxException e)\n\t\t{\n\t\t\tthrow new RuntimeException(e);\n\t\t}\n\t\treturn UriComponentsBuilder.fromPath(uri.getPath())\n\t\t\t\t.query(uri.getQuery())\n\t\t\t\t.build();\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/util/ImageViewUtilsTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.testfx.framework.junit5.ApplicationExtension;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\n@ExtendWith(ApplicationExtension.class)\nclass ImageViewUtilsTest\n{\n\t@Test\n\tvoid limitMaximumImageSize_Width_Exceeded()\n\t{\n\t\tvar dimension = ImageViewUtils.limitMaximumImageSize(100, 50, 50, 50);\n\t\tassertEquals(50, dimension.getWidth());\n\t\tassertTrue(dimension.getHeight() < 50);\n\t}\n\n\t@Test\n\tvoid limitMaximumImageSize_Height_Exceeded()\n\t{\n\t\tvar dimension = ImageViewUtils.limitMaximumImageSize(100, 50, 100, 25);\n\t\tassertEquals(25, dimension.getHeight());\n\t\tassertTrue(dimension.getHeight() < 100);\n\t}\n\n\t@Test\n\tvoid limitMaximumImageSize_WidthAndHeight_Exceeded()\n\t{\n\t\tvar dimension = ImageViewUtils.limitMaximumImageSize(800, 600, 320, 240);\n\t\tassertTrue(dimension.getWidth() <= 320);\n\t\tassertTrue(dimension.getHeight() <= 240);\n\t}\n\n\t@Test\n\tvoid limitMaximumImageSize_WidthAndHeight_Exceeded_Different_Ratio()\n\t{\n\t\tvar dimension = ImageViewUtils.limitMaximumImageSize(800, 600, 50, 50);\n\t\tassertTrue(dimension.getWidth() <= 50);\n\t\tassertTrue(dimension.getHeight() <= 50);\n\t}\n\n\t@Test\n\tvoid limitMaximumImageSize_Width_Not_Exceeded()\n\t{\n\t\tvar dimension = ImageViewUtils.limitMaximumImageSize(100, 50, 100, 50);\n\t\tassertEquals(100, dimension.getWidth());\n\t\tassertEquals(50, dimension.getHeight());\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/util/RangeTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass RangeTest\n{\n\t@Test\n\tvoid testIndexConstructor_hasRangeAndAccessors()\n\t{\n\t\t// Step 2: index-based constructor\n\t\tRange r = new Range(5, 10); // (plan step 2)\n\t\tassertEquals(5, r.start(), \"start should match given start\");\n\t\tassertEquals(10, r.end(), \"end should match given end\");\n\t\tassertTrue(r.hasRange(), \"end > start -> hasRange should be true\");\n\n\t\tRange empty = new Range(7, 7);\n\t\tassertFalse(empty.hasRange(), \"start == end -> hasRange should be false\");\n\t}\n\n\t@Test\n\tvoid testMatcherConstructor_wholeMatch()\n\t{\n\t\t// Step 3a: whole-match (no capture groups)\n\t\tPattern p = Pattern.compile(\"hello\");\n\t\tMatcher m = p.matcher(\"hello world\");\n\t\tassertTrue(m.find(), \"pattern should find a match\");\n\n\t\tRange r = new Range(m); // (plan step 3a)\n\t\tassertEquals(m.start(), r.start(), \"start should equal match start\");\n\t\tassertEquals(m.end(), r.end(), \"end should equal match end\");\n\t\t// For whole-match constructor, Range.group remains default (0) and groupName stays null\n\t\tassertEquals(0, r.group(), \"group should be 0 for whole-match constructor\");\n\t\tassertNull(r.groupName(), \"groupName should be null for whole-match constructor\");\n\t}\n\n\t@Test\n\tvoid testMatcherConstructor_unnamedGroup()\n\t{\n\t\t// Step 3b: unnamed capture group\n\t\tPattern p = Pattern.compile(\"(foo)bar\");\n\t\tMatcher m = p.matcher(\"foobar\");\n\t\tassertTrue(m.find(), \"pattern should find a match\");\n\n\t\tRange r = new Range(m); // (plan step 3b)\n\t\t// The constructor will pick the first matched capturing group (group 1)\n\t\tassertEquals(m.start(1), r.start(), \"start should match capture group start\");\n\t\tassertEquals(m.end(1), r.end(), \"end should match capture group end\");\n\t\tassertEquals(1, r.group(), \"group should be 1 for the first capturing group\");\n\t\t// For unnamed group the code sets groupName to empty string\n\t\tassertEquals(\"\", r.groupName(), \"groupName should be empty string for unnamed capture group\");\n\t}\n\n\t@Test\n\tvoid testMatcherConstructor_namedGroup()\n\t{\n\t\t// Step 3c: named capture group\n\t\tPattern p = Pattern.compile(\"(?<name>bar)baz\");\n\t\tMatcher m = p.matcher(\"barbaz\");\n\t\tassertTrue(m.find(), \"pattern should find a match with named group\");\n\n\t\tRange r = new Range(m); // (plan step 3c)\n\t\t// Named group 'name' is group 1 here\n\t\tassertEquals(m.start(1), r.start(), \"start should match named group start\");\n\t\tassertEquals(m.end(1), r.end(), \"end should match named group end\");\n\t\tassertEquals(1, r.group(), \"group should be the numeric index of the named group\");\n\t\tassertEquals(\"name\", r.groupName(), \"groupName should be the name of the named capture group\");\n\t}\n\n\t@Test\n\tvoid testOuterRange_otherAfter()\n\t{\n\t\t// Step 4: outerRange when other is after us\n\t\tRange a = new Range(0, 3);\n\t\tRange b = new Range(5, 8);\n\t\tRange gap = a.outerRange(b); // (plan step 4)\n\t\tassertEquals(3, gap.start(), \"gap start should be end of first range\");\n\t\tassertEquals(5, gap.end(), \"gap end should be start of second range\");\n\t\tassertTrue(gap.hasRange(), \"non-empty gap should have range\");\n\t}\n\n\t@Test\n\tvoid testOuterRange_otherBefore()\n\t{\n\t\t// Step 4: outerRange when other is before us\n\t\tRange a = new Range(10, 15);\n\t\tRange b = new Range(2, 7);\n\t\tRange gap = a.outerRange(b); // (plan step 4)\n\t\tassertEquals(7, gap.start(), \"gap start should be end of the other range\");\n\t\tassertEquals(10, gap.end(), \"gap end should be start of this range\");\n\t\tassertTrue(gap.hasRange(), \"non-overlapping ranges should yield a non-empty gap\");\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/util/SmileyUtilsTest.java",
    "content": "/*\n * Copyright (c) 2019-2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass SmileyUtilsTest\n{\n\t@ParameterizedTest\n\t@CsvSource({\n\t\t\t\":-),\\uD83D\\uDE42\",\n\t\t\t\"hello :-),hello \\uD83D\\uDE42\",\n\t\t\t\":-) works, \\uD83D\\uDE42 works\",\n\t\t\t\":-) :-) :-), \\uD83D\\uDE42 \\uD83D\\uDE42 \\uD83D\\uDE42\",\n\t\t\t\":-) :-( :-D :-) :-( :-( :-D, \\uD83D\\uDE42 \\uD83D\\uDE41 \\uD83D\\uDE03 \\uD83D\\uDE42 \\uD83D\\uDE41 \\uD83D\\uDE41 \\uD83D\\uDE03\",\n\t})\n\tvoid SmileysToUnicode_Replace(String input, String expected)\n\t{\n\t\tvar actualValue = SmileyUtils.smileysToUnicode(input);\n\t\tassertEquals(expected, actualValue);\n\t}\n\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(SmileyUtils.class);\n\t}\n\n\t@Test\n\tvoid SmileysToUnicode_NoReplace()\n\t{\n\t\tvar value = SmileyUtils.smileysToUnicode(\"hello:-)\");\n\t\tassertEquals(\"hello:-)\", value);\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/util/TextInputControlUtilsTest.java",
    "content": "/*\n * Copyright (c) 2025 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass TextInputControlUtilsTest\n{\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"hey\",\n\t\t\t\"hello ;) how are you? ;)\",\n\t\t\t\" \"\n\t})\n\tvoid pasteGuessedContent_Code_False(String input)\n\t{\n\t\tassertFalse(TextInputControlUtils.isSourceCode(input));\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"\"\"\n\t\t\t\t\tclass TextInputControlUtilsTest {\n\t\t\t\t\t    @ParameterizedTest\n\t\t\t\t\t    @ValueSource(strings = {\n\t\t\t\t\t        \"hey\",\n\t\t\t\t\t        \"hello ;) how are you? ;)\",\n\t\t\t\t\t        \"simple text\",\n\t\t\t\t\t        \"another example\"\n\t\t\t\t\t    })\n\t\t\t\t\t    void pasteGuessedContent_False(String input) {\n\t\t\t\t\t        assertFalse(TextInputControlUtils.isSourceCode(input));\n\t\t\t\t\t    }\n\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\tfunction calculateTotal(items) {\n\t\t\t\t\t  return items.reduce((sum, item) => sum + item.price, 0);\n\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\t\tfunction calculateTotal(items) {\n\t\t\t\t\t\t  return items.reduce((sum, item) => sum + item.price, 0);\n\t\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\ttry {\n\t\t\t\t\t  const response = await apiCall();\n\t\t\t\t\t  setData(response.data);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t  console.error('Failed:', error);\n\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\t.container {\n\t\t\t\t\t  display: flex;\n\t\t\t\t\t  justify-content: center;\n\t\t\t\t\t  align-items: center;\n\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\t<div class=\"header\">\n\t\t\t\t\t  <h1>Welcome</h1>\n\t\t\t\t\t</div>\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\tfor (let i = 0; i < array.length; i++) {\n\t\t\t\t\t  process(array[i]);\n\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\t{\n\t\t\t\t\t  \"user\": {\n\t\t\t\t\t    \"id\": 123,\n\t\t\t\t\t    \"name\": \"Alice\"\n\t\t\t\t\t  }\n\t\t\t\t\t}\n\t\t\t\t\t\"\"\",\n\t\t\t\"\"\"\n\t\t\t\t\tlet counter: number = 0;\n\t\t\t\t\tconst MAX_RETRIES: number = 3;\n\t\t\t\t\t\"\"\"\n\t})\n\tvoid pasteGuessedContent_Code_True(String input)\n\t{\n\t\tassertTrue(TextInputControlUtils.isSourceCode(input));\n\t}\n\n\t@Test\n\tvoid pasteGuessedContent_Citation_False()\n\t{\n\t\tassertFalse(TextInputControlUtils.isCitation(\"this is just some text\"));\n\t}\n\n\t@Test\n\tvoid pasteGuessedContent_Citation_True()\n\t{\n\t\tassertTrue(TextInputControlUtils.isCitation(\"Flying Pigs Reported That a Cow Managed to Stop Global Warming by Eating Using Only One Stomach\"));\n\t}\n\n\t@Test\n\tvoid pasteGuessedContent_Uri_False()\n\t{\n\t\tassertFalse(TextInputControlUtils.isUri(\"not an url\"));\n\t}\n\n\t@Test\n\tvoid pasteGuessedContent_Uri_True()\n\t{\n\t\tassertTrue(TextInputControlUtils.isUri(\"https://example.com\"));\n\t\tassertTrue(TextInputControlUtils.isUri(\"http://example.com\"));\n\t\tassertTrue(TextInputControlUtils.isUri(\"retroshare://foo\"));\n\t}\n}"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/util/UiUtilsTest.java",
    "content": "/*\n * Copyright (c) 2023 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport io.xeres.testutils.TestUtils;\nimport org.junit.jupiter.api.Test;\n\nclass UiUtilsTest\n{\n\t@Test\n\tvoid Instance_ThrowsException() throws NoSuchMethodException\n\t{\n\t\tTestUtils.assertUtilityClass(UiUtils.class);\n\t}\n}\n"
  },
  {
    "path": "ui/src/test/java/io/xeres/ui/support/util/UriUtilsTest.java",
    "content": "/*\n * Copyright (c) 2026 by David Gerber - https://zapek.com\n *\n * This file is part of Xeres.\n *\n * Xeres is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Xeres is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Xeres.  If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage io.xeres.ui.support.util;\n\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass UriUtilsTest\n{\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"https://zapek.com\",\n\t\t\t\"https://xeres.io/docs/\",\n\t\t\t\"https://01.com\",\n\t\t\t\"mailto:foo@bar.com\",\n\t\t\t\"mailto:admin\",\n\t\t\t\"tel:+12345678\",\n\t\t\t\"retroshare://forum?name=Xeres&id=1eff9350b5d8eca8feef04fd914fc365\",\n\t\t\t\"01.Main\",\n\t\t\t\"http://f7vdjudujlxlvn6xru2tdllvzyejavmb27r7ytatjszrumpxlt4a.b32.i2p/\"\n\t})\n\tvoid isSafeUrl(String url)\n\t{\n\t\tassertTrue(UriUtils.isSafeEnough(url));\n\t}\n\n\t@ParameterizedTest\n\t@ValueSource(strings = {\n\t\t\t\"http://zapek.com\",\n\t\t\t\"https://localhost\",\n\t\t\t\"https://127.0.0.1\",\n\t\t\t\"https://127.0.0.2\",\n\t\t\t\"https://192.168.1.1\",\n\t\t\t\"https://10.0.0.1\",\n\t\t\t\"https://172.16.0.1\",\n\t\t\t\"https://127.1\",\n\t\t\t\"https://127.2\",\n\t\t\t\"https://[::1]\",\n\t\t\t\"https://[2001:0db8:85a3:0000:8a2e:0370:7334]\",\n\t\t\t\"https://124.2.4.58\",\n\t\t\t\"https://zapek.com:8080\",\n\t\t\t\"ftp://some.site.com\",\n\t\t\t\"file:///etc/passwd\",\n\t\t\t\"file:///C:/Users/Name/file.txt\",\n\t\t\t\"file:/C/users/name/file.txt\",\n\t\t\t\"https://0x7f000001/\",\n\t\t\t\"https://0X7f000001/\",\n\t\t\t\"https://0x7F.0.0000.00000001/\",\n\t\t\t\"https://0X7F.0.0000.00000001/\"\n\t})\n\tvoid isMaliciousUrl(String url)\n\t{\n\t\tassertFalse(UriUtils.isSafeEnough(url));\n\t}\n}"
  }
]