diff --git a/composer.lock b/composer.lock index af8a6ff29..694fbeccc 100644 --- a/composer.lock +++ b/composer.lock @@ -149,12 +149,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -302,8 +302,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -515,16 +515,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.6.1", + "version": "v7.8.2", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "9ac8eda68f17acda4dad4aa02ecdcc327d7e6675" + "reference": "22c45ae560825291f11b32455878b89e718e758c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9ac8eda68f17acda4dad4aa02ecdcc327d7e6675", - "reference": "9ac8eda68f17acda4dad4aa02ecdcc327d7e6675", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/22c45ae560825291f11b32455878b89e718e758c", + "reference": "22c45ae560825291f11b32455878b89e718e758c", "shasum": "" }, "require": { @@ -535,24 +535,24 @@ "fidry/cpu-core-counter": "^1.2.0", "jean85/pretty-package-versions": "^2.1.0", "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.7", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.4.4", - "sebastian/environment": "^7.2.0", - "symfony/console": "^6.4.14 || ^7.1.7", - "symfony/process": "^6.4.14 || ^7.1.7" + "phpunit/php-code-coverage": "^11.0.8 || ^12", + "phpunit/php-file-iterator": "^5.1.0 || ^6", + "phpunit/php-timer": "^7.0.1 || ^8", + "phpunit/phpunit": "^11.5.7 || ^12.0.1", + "sebastian/environment": "^7.2.0 || ^8", + "symfony/console": "^6.4.17 || ^7.2.1", + "symfony/process": "^6.4.15 || ^7.2.0" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2", - "phpstan/phpstan-deprecation-rules": "^2", - "phpstan/phpstan-phpunit": "^2", - "phpstan/phpstan-strict-rules": "^2", - "squizlabs/php_codesniffer": "^3.11.1", - "symfony/filesystem": "^6.4.13 || ^7.1.6" + "phpstan/phpstan": "^2.1.5", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-phpunit": "^2.0.4", + "phpstan/phpstan-strict-rules": "^2.0.3", + "squizlabs/php_codesniffer": "^3.11.3", + "symfony/filesystem": "^6.4.13 || ^7.2.0" }, "bin": [ "bin/paratest", @@ -592,7 +592,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.6.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.8.2" }, "funding": [ { @@ -604,7 +604,7 @@ "type": "paypal" } ], - "time": "2024-12-05T10:55:39+00:00" + "time": "2025-02-19T07:26:44+00:00" }, { "name": "fidry/cpu-core-counter", @@ -728,16 +728,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "024473a478be9df5fdaca2c793f2232fe788e414" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", + "reference": "024473a478be9df5fdaca2c793f2232fe788e414", "shasum": "" }, "require": { @@ -776,7 +776,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" }, "funding": [ { @@ -784,20 +784,20 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-02-12T12:17:51+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.4.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "447a020a1f875a434d62f2a401f53b82a396e494" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", + "reference": "447a020a1f875a434d62f2a401f53b82a396e494", "shasum": "" }, "require": { @@ -840,9 +840,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2024-12-30T11:07:19+00:00" }, { "name": "phar-io/manifest", @@ -964,16 +964,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.7", + "version": "11.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca" + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f7f08030e8811582cc459871d28d6f5a1a4d35ca", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/418c59fd080954f8c4aa5631d9502ecda2387118", + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118", "shasum": "" }, "require": { @@ -992,7 +992,7 @@ "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^11.4.1" + "phpunit/phpunit": "^11.5.0" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -1030,7 +1030,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.7" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.8" }, "funding": [ { @@ -1038,7 +1038,7 @@ "type": "github" } ], - "time": "2024-10-09T06:21:38+00:00" + "time": "2024-12-11T12:34:27+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1287,16 +1287,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.0", + "version": "11.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0569902506a6c0878930b87ea79ec3b50ea563f7" + "reference": "c9bd61aab12f0fc5e82ecfe621ff518a1d1f1049" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0569902506a6c0878930b87ea79ec3b50ea563f7", - "reference": "0569902506a6c0878930b87ea79ec3b50ea563f7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c9bd61aab12f0fc5e82ecfe621ff518a1d1f1049", + "reference": "c9bd61aab12f0fc5e82ecfe621ff518a1d1f1049", "shasum": "" }, "require": { @@ -1310,14 +1310,14 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.7", + "phpunit/php-code-coverage": "^11.0.8", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.1", - "sebastian/comparator": "^6.2.1", + "sebastian/code-unit": "^3.0.2", + "sebastian/comparator": "^6.3.0", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.0", "sebastian/exporter": "^6.3.0", @@ -1368,7 +1368,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.0" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.8" }, "funding": [ { @@ -1384,7 +1384,7 @@ "type": "tidelift" } ], - "time": "2024-12-06T05:57:38+00:00" + "time": "2025-02-18T06:26:59+00:00" }, { "name": "psr/container", @@ -1498,23 +1498,23 @@ }, { "name": "sebastian/code-unit", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268" + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/6bb7d09d6623567178cf54126afa9c2310114268", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { @@ -1543,7 +1543,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.2" }, "funding": [ { @@ -1551,7 +1551,7 @@ "type": "github" } ], - "time": "2024-07-03T04:44:28+00:00" + "time": "2024-12-12T09:59:06+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1611,16 +1611,16 @@ }, { "name": "sebastian/comparator", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739" + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/43d129d6a0f81c78bee378b46688293eb7ea3739", - "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/d4e47a769525c4dd38cea90e5dcd435ddbbc7115", + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115", "shasum": "" }, "require": { @@ -1633,6 +1633,9 @@ "require-dev": { "phpunit/phpunit": "^11.4" }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, "type": "library", "extra": { "branch-alias": { @@ -1676,7 +1679,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.2.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.0" }, "funding": [ { @@ -1684,7 +1687,7 @@ "type": "github" } ], - "time": "2024-10-31T05:30:08+00:00" + "time": "2025-01-06T10:28:19+00:00" }, { "name": "sebastian/complexity", @@ -2364,16 +2367,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.1", + "version": "3.11.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87" + "reference": "ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10", + "reference": "ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10", "shasum": "" }, "require": { @@ -2438,9 +2441,13 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-11-16T12:02:36+00:00" + "time": "2025-01-23T17:04:15+00:00" }, { "name": "staabm/side-effects-detector", @@ -2496,16 +2503,16 @@ }, { "name": "symfony/console", - "version": "v7.2.0", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "23c8aae6d764e2bae02d2a99f7532a7f6ed619cf" + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/23c8aae6d764e2bae02d2a99f7532a7f6ed619cf", - "reference": "23c8aae6d764e2bae02d2a99f7532a7f6ed619cf", + "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", "shasum": "" }, "require": { @@ -2569,7 +2576,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.0" + "source": "https://github.com/symfony/console/tree/v7.2.1" }, "funding": [ { @@ -2585,7 +2592,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2024-12-11T03:49:26+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -2831,12 +2838,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { diff --git a/example.php b/example.php index e8f4e3621..8ddf2e3bd 100644 --- a/example.php +++ b/example.php @@ -3,6 +3,7 @@ include_once 'vendor/autoload.php'; use Appwrite\SDK\Language\GraphQL; +use Appwrite\SDK\Language\KMP; use Appwrite\Spec\Swagger2; use Appwrite\SDK\SDK; use Appwrite\SDK\Language\Web; @@ -464,6 +465,31 @@ function getSSLPage($url) { ; $sdk->generate(__DIR__ . '/examples/android'); + // KMP + + $sdk = new SDK(new KMP(), new Swagger2($spec)); + + $sdk + ->setName('KMP') + ->setNamespace('io appwrite') + ->setDescription('Appwrite is an open-source backend as a service server that abstract and simplify complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Flutter SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to https://appwrite.io/docs') + ->setShortDescription('Appwrite KMP SDK') + ->setURL('https://example.com') + ->setGitUserName('appwrite') + ->setGitRepoName('sdk-for-kmp') + ->setLogo('https://appwrite.io/v1/images/console.png') + ->setLicenseContent('test test test') + ->setWarning('**This SDK is compatible with Appwrite server version 0.7.x. For older versions, please check previous releases.**') + ->setChangelog('**CHANGELOG**') + ->setVersion('0.0.0-SNAPSHOT') + ->setTwitter('appwrite_io') + ->setDiscord('564160730845151244', 'https://appwrite.io/discord') + ->setDefaultHeaders([ + 'x-appwrite-response-format' => '1.6.0', + ]) + ; + $sdk->generate(__DIR__ . '/examples/kmp'); + // Kotlin $sdk = new SDK(new Kotlin(), new Swagger2($spec)); diff --git a/src/SDK/Language/Android.php b/src/SDK/Language/Android.php index 8f1fdb1f9..22b8af970 100644 --- a/src/SDK/Language/Android.php +++ b/src/SDK/Language/Android.php @@ -339,11 +339,11 @@ public function getFiles(): array ]; } - protected function getReturnType(array $method, array $spec, string $namespace, string $generic = 'T'): string + protected function getReturnType(array $method, array $spec, string $namespace, string $generic = 'T', bool $withGeneric = true): string { if ($method['type'] === 'webAuth') { return 'Bool'; } - return parent::getReturnType($method, $spec, $namespace, $generic); + return parent::getReturnType($method, $spec, $namespace, $generic, $withGeneric); } } diff --git a/src/SDK/Language/KMP.php b/src/SDK/Language/KMP.php new file mode 100644 index 000000000..5c6aae793 --- /dev/null +++ b/src/SDK/Language/KMP.php @@ -0,0 +1,636 @@ +<?php + +namespace Appwrite\SDK\Language; + +use Twig\TwigFilter; + +class KMP extends Kotlin +{ + /** + * @return string + */ + public function getName(): string + { + return 'KMP'; + } + + public function getFilters(): array + { + $filters = array_filter(parent::getFilters(), function ($filter) { + return $filter->getName() !== 'propertyType'; + }); + + $filters[] = new TwigFilter('propertyType', function (array $property, array $spec, string $generic = 'T', $contextual = false) { + return $this->getPropertyType($property, $spec, $generic, $contextual); + }); + + $filters[] = new TwigFilter('webAuthServices', function (array $spec) { + return $this->getWebAuthServices($spec); + }); + + $filters[] = new TwigFilter('propertySerializerName', function (array $property) { + return $this->getPropertySerializerName($property); + }); + + return $filters; + } + + protected function getReturnType(array $method, array $spec, string $namespace, string $generic = 'T', bool $withGeneric = true): string + { + if ($method['type'] === 'webAuth') { + return 'Bool'; + } + return parent::getReturnType($method, $spec, $namespace, $generic, $withGeneric); + } + + protected function getWebAuthServices(array $spec): array + { + $webAuthServices = []; + foreach ($spec['services'] as $service) { + $webAuthMethods = []; + $hasWebAuth = false; + foreach ($service['methods'] as $method) { + if ($method['type'] === 'webAuth') { + $webAuthMethods[] = [ + 'methodName' => $method['name'], + 'parameters' => $method['parameters'], + 'path' => $method['path'], + 'auth' => $method['auth'] + ]; + $hasWebAuth = true; + } + } + if ($hasWebAuth) { + $webAuthServices[] = [ + 'methods' => $webAuthMethods, + 'className' => $service['name'] + ]; + } + } + return $webAuthServices; + } + + + protected function getPropertyType(array $property, array $spec, string $generic = 'T', bool $contextual = false): string + { + $type = parent::getPropertyType($property, $spec, $generic); + if ($contextual && ($type === 'List<Any>' || $type === 'List<Any>?')) { + $type = 'List<@Contextual Any>'; + } + return $type; + } + + protected function getPropertySerializerName(array $property): string + { + if (isset($property['enumName'])) { + return 'io.appwrite.enums.' . \ucfirst($property['enumName']) . 'Serializer'; + } + if (!empty($property['enumValues'])) { + return 'io.appwrite.enums.' . \ucfirst($property['name']) . 'Serializer'; + } + if (isset($property['items'])) { + $property['array'] = $property['items']; + } + + $name = match ($property['type']) { + self::TYPE_INTEGER => 'Long.serializer()', + self::TYPE_NUMBER => 'Double.serializer()', + self::TYPE_STRING => 'String.serializer()', + self::TYPE_BOOLEAN => 'Boolean.serializer()', + self::TYPE_ARRAY => (!empty(($property['array'] ?? [])['type']) && !\is_array($property['array']['type'])) + ? 'ListSerializer(' . $this->getPropertySerializerName($property['array']) . ')' + : 'ListSerializer(DynamicLookupSerializer)', + self::TYPE_OBJECT => 'DynamicLookupSerializer', + default => $property['type'] . 'Serializer', + }; + + return $name; + } + + + public function getFiles(): array + { + return [ + // Root project config + [ + 'scope' => 'copy', + 'destination' => '.github/workflows/publish.yml', + 'template' => '/kmp/.github/workflows/publish.yml', + ], +// [ +// 'scope' => 'method', +// 'destination' => 'docs/examples/kotlin/{{service.name | caseLower}}/{{method.name | caseDash}}.md', +// 'template' => '/kmp/docs/kotlin/example.md.twig', +// ], +// [ +// 'scope' => 'method', +// 'destination' => 'docs/examples/java/{{service.name | caseLower}}/{{method.name | caseDash}}.md', +// 'template' => '/kmp/docs/java/example.md.twig', +// ], + + // Gradle files + [ + 'scope' => 'copy', + 'destination' => 'gradle/wrapper/gradle-wrapper.jar', + 'template' => '/kmp/gradle/wrapper/gradle-wrapper.jar', + ], + [ + 'scope' => 'copy', + 'destination' => 'gradle/wrapper/gradle-wrapper.properties', + 'template' => '/kmp/gradle/wrapper/gradle-wrapper.properties', + ], + [ + 'scope' => 'copy', + 'destination' => 'gradle/libs.versions.toml', + 'template' => '/kmp/gradle/libs.versions.toml', + ], + + // Root files + [ + 'scope' => 'copy', + 'destination' => '.gitignore', + 'template' => '/kmp/.gitignore', + ], + [ + 'scope' => 'default', + 'destination' => 'build.gradle.kts', + 'template' => '/kmp/build.gradle.kts.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'settings.gradle.kts', + 'template' => '/kmp/settings.gradle.kts', + ], + [ + 'scope' => 'default', + 'destination' => 'CHANGELOG.md', + 'template' => '/kmp/CHANGELOG.md.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'README.md', + 'template' => '/kmp/README.md.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'LICENSE.md', + 'template' => '/kmp/LICENSE.md.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'gradlew', + 'template' => '/kmp/gradlew', + ], + [ + 'scope' => 'default', + 'destination' => 'gradlew.bat', + 'template' => '/kmp/gradlew.bat', + ], + [ + 'scope' => 'default', + 'destination' => 'gradle.properties', + 'template' => '/kmp/gradle.properties', + ], + + // Shared module + [ + 'scope' => 'default', + 'destination' => 'shared/build.gradle.kts', + 'template' => '/kmp/shared/build.gradle.kts.twig', + ], + + // Common Main + // Common Main root files + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/BaseClient.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/BaseClient.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/Client.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/Client.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/ID.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/ID.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/Permission.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/Permission.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/Query.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/Query.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/Role.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/Role.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/Service.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/Service.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/WebAuthComponent.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/WebAuthComponent.kt.twig', + ], + + + // Coroutines + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/coroutines/Callback.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/coroutines/Callback.kt.twig', + ], + + // Enums + [ + 'scope' => 'enum', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/enums/{{ enum.name | caseUcfirst }}.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/enums/Enum.kt.twig', + ], + + // Exceptions + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/exceptions/Exception.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/exceptions/Exception.kt.twig', + ], + + // Extensions + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/CollectionExtensions.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/extensions/CollectionExtensions.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/JsonExtensions.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonExtensions.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/TypeExtensions.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/extensions/TypeExtensions.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/JsonObjectBuilderExtensions.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonObjectBuilderExtensions.kt.twig', + ], + + // File Operations + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/fileOperations/FileOperations.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/fileOperations/FileOperations.kt.twig', + ], + + // Models + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/models/InputFile.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/models/InputFile.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/models/RealtimeModels.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/models/RealtimeModels.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/models/UploadProgress.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/models/UploadProgress.kt.twig', + ], + + // Serializers + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/serializers/DynamicLookupSerializer.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/serializers/DynamicLookupSerializer.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/serializers/StringCollectionSeriailizer.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/serializers/StringCollectionSeriailizer.kt.twig', + ], + + // Services + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/services/Realtime.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/services/Realtime.kt.twig', + ], + [ + 'scope' => 'service', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/services/{{service.name | caseUcfirst}}.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/services/Service.kt.twig', + ], + + // Web Interface + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/ParsedUrl.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/webInterface/ParsedUrl.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/UrlParser.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/webInterface/UrlParser.kt.twig', + ], + + + // Android Main + // Android Main root files + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/AllCertsTrustManager.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/AllCertsTrustManager.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/Client.android.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/Client.android.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/HttpClientConfig.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/HttpClientConfig.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/KeepAliveService.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/KeepAliveService.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/WebAuthComponent.android.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/WebAuthComponent.android.kt.twig', + ], + + // Cookies + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/cookies/SerializableCookie.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/cookies/SerializableCookie.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/cookies/stores/DataStoreManager.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreManager.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/cookies/stores/DataStoreCookieStorage.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreCookieStorage.kt.twig', + ], + + // Extensions + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/OAuth2Extensions.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig', + ], + + // Models + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/models/InputFile.android.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/models/InputFile.android.kt.twig', + ], + + // Views + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/views/CallbackActivity.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/views/CallbackActivity.kt.twig', + ], + + // Web Interface + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/AndroidParsedUrl.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/webInterface/AndroidParsedUrl.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/androidMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/UrlParser.android.kt', + 'template' => '/kmp/shared/src/androidMain/kotlin/io/package/webInterface/UrlParser.android.kt.twig', + ], + + // iOS Main + // iOS Main root files + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/Client.ios.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/Client.ios.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/HttpClientConfig.ios.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/HttpClientConfig.ios.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/WebAuthComponent.ios.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/WebAuthComponent.ios.kt.twig', + ], + + // Cookies + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/cookies/IosCookieStorage.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/cookies/IosCookieStorage.kt.twig', + ], + + // Extensions + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/OAuth2Extensions.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig', + ], + + // Models + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/models/InputFile.ios.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/models/InputFile.ios.kt.twig', + ], + + // Web Interface + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/IosParsedUrl.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/webInterface/IosParsedUrl.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/iosMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/UrlParser.ios.kt', + 'template' => '/kmp/shared/src/iosMain/kotlin/io/package/webInterface/UrlParser.ios.kt.twig', + ], + + + // JVM Main + // JVM Main root files + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/AllCertsTrustManager.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/AllCertsTrustManager.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/Client.jvm.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/Client.jvm.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/HttpClient.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/HttpClient.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/WebAuthComponent.jvm.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/WebAuthComponent.jvm.kt.twig', + ], + + // Extensions + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/extensions/OAuth2Extensions.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig', + ], + + // Models + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/models/InputFile.jvm.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/models/InputFile.jvm.kt.twig', + ], + + // Web Interface + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/JvmParsedUrl.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/JvmParsedUrl.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'shared/src/jvmMain/kotlin/{{ sdk.namespace | caseSlash }}/webInterface/UrlParser.jvm.kt', + 'template' => '/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/UrlParser.jvm.kt.twig', + ], + + + // Android App + // Android App root files + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/AndroidManifest.xml', + 'template' => '/kmp/androidApp/src/main/AndroidManifest.xml', + ], + +// Java files + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/java/{{ sdk.namespace | caseSlash }}/android/MainActivity.kt', + 'template' => '/kmp/androidApp/src/main/java/io/package/android/MainActivity.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/java/{{ sdk.namespace | caseSlash }}/android/services/MessagingService.kt', + 'template' => '/kmp/androidApp/src/main/java/io/package/android/services/MessagingService.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/java/{{ sdk.namespace | caseSlash }}/android/ui/accounts/AccountsFragment.kt', + 'template' => '/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsFragment.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/java/{{ sdk.namespace | caseSlash }}/android/ui/accounts/AccountsViewModel.kt', + 'template' => '/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsViewModel.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/java/{{ sdk.namespace | caseSlash }}/android/utils/Client.kt', + 'template' => '/kmp/androidApp/src/main/java/io/package/android/utils/Client.kt.twig', + ], + [ + 'scope' => 'default', + 'destination' => 'androidApp/src/main/java/{{ sdk.namespace | caseSlash }}/android/utils/Event.kt', + 'template' => '/kmp/androidApp/src/main/java/io/package/android/utils/Event.kt.twig', + ], + +// Resource files + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/drawable/ic_launcher_background.xml', + 'template' => '/kmp/androidApp/src/main/res/drawable/ic_launcher_background.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/drawable/ic_launcher_foreground.xml', + 'template' => '/kmp/androidApp/src/main/res/drawable/ic_launcher_foreground.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/layout/activity_main.xml', + 'template' => '/kmp/androidApp/src/main/res/layout/activity_main.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/layout/fragment_account.xml', + 'template' => '/kmp/androidApp/src/main/res/layout/fragment_account.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', + 'template' => '/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml', + 'template' => '/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/values/colors.xml', + 'template' => '/kmp/androidApp/src/main/res/values/colors.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/values/strings.xml', + 'template' => '/kmp/androidApp/src/main/res/values/strings.xml', + ], + [ + 'scope' => 'copy', + 'destination' => 'androidApp/src/main/res/values/themes.xml', + 'template' => '/kmp/androidApp/src/main/res/values/themes.xml', + ], + + + // Models, Services, and other common components + [ + 'scope' => 'service', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/services/{{service.name | caseUcfirst}}.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/services/Service.kt.twig', + ], + [ + 'scope' => 'definition', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/models/{{ definition.name | caseUcfirst }}.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/models/Model.kt.twig', + ], + [ + 'scope' => 'enum', + 'destination' => 'shared/src/commonMain/kotlin/{{ sdk.namespace | caseSlash }}/enums/{{ enum.name | caseUcfirst }}.kt', + 'template' => '/kmp/shared/src/commonMain/kotlin/io/package/enums/Enum.kt.twig', + ], + ]; + } +} diff --git a/src/SDK/Language/Kotlin.php b/src/SDK/Language/Kotlin.php index 764949528..7cf53f8ff 100644 --- a/src/SDK/Language/Kotlin.php +++ b/src/SDK/Language/Kotlin.php @@ -426,8 +426,8 @@ public function getFiles(): array public function getFilters(): array { return [ - new TwigFilter('returnType', function (array $method, array $spec, string $namespace, string $generic = 'T') { - return $this->getReturnType($method, $spec, $namespace, $generic); + new TwigFilter('returnType', function (array $method, array $spec, string $namespace, string $generic = 'T', bool $withGeneric = true) { + return $this->getReturnType($method, $spec, $namespace, $generic, $withGeneric); }), new TwigFilter('modelType', function (array $property, array $spec, string $generic = 'T') { return $this->getModelType($property, $spec, $generic); @@ -447,7 +447,7 @@ public function getFilters(): array ]; } - protected function getReturnType(array $method, array $spec, string $namespace, string $generic = 'T'): string + protected function getReturnType(array $method, array $spec, string $namespace, string $generic = 'T', bool $withGeneric = true): string { if ($method['type'] === 'webAuth') { return 'String'; @@ -466,7 +466,7 @@ protected function getReturnType(array $method, array $spec, string $namespace, $ret = $this->toPascalCase($method['responseModel']); - if ($this->hasGenericType($method['responseModel'], $spec)) { + if ($this->hasGenericType($method['responseModel'], $spec) && $withGeneric) { $ret .= '<' . $generic . '>'; } diff --git a/templates/android/library/src/main/java/io/package/WebAuthComponent.kt.twig b/templates/android/library/src/main/java/io/package/WebAuthComponent.kt.twig index a07509ac2..f55fa7123 100644 --- a/templates/android/library/src/main/java/io/package/WebAuthComponent.kt.twig +++ b/templates/android/library/src/main/java/io/package/WebAuthComponent.kt.twig @@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity import androidx.browser.customtabs.CustomTabsIntent import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import {{ sdk.namespace | caseDot }}.services.KeepAliveService import kotlinx.coroutines.delay import kotlin.collections.component1 import kotlin.collections.component2 @@ -18,34 +17,31 @@ import kotlin.collections.set * Used to authenticate with external OAuth2 providers. Launches browser windows and handles * suspension until the user completes the process or otherwise returns to the app. */ -internal class WebAuthComponent { +actual class WebAuthComponent(private val activity: ComponentActivity) { - companion object : DefaultLifecycleObserver { + internal companion object : DefaultLifecycleObserver { private var suspended = false - private val callbacks = mutableMapOf<String, (((Result<String>) -> Unit)?)>() + private val callbacks = mutableMapOf<String, ((Result<String>) -> Unit)?>() override fun onResume(owner: LifecycleOwner) { + // When the activity resumes, end the suspension so that the caller can continue. suspended = false } /** - * Authenticate Session with OAuth2 + * Authenticate session using OAuth2. * - * Launches a chrome custom tab from the given activity and directs to the given url, - * suspending until the user returns to the app, at which point the given [onComplete] callback - * will run, passing the callback url from the intent used to launch the [CallbackActivity], - * or an [IllegalStateException] in the case the user closed the window or returned to the - * app without passing through the [CallbackActivity]. + * Launches a Chrome Custom Tab from the provided activity to open the given URL. + * Once the user returns to the app (resuming the activity), the provided callback is invoked. * - * - * @param activity The activity to launch the browser from and observe the lifecycle of - * @param url The url to launch - * @param callbackUrlScheme The callback url scheme used to key the given callback - * @param onComplete The callback to run when a result (success or failure) is received + * @param activity The activity used to launch the browser and observe lifecycle events. + * @param url The URL to open. + * @param callbackUrlScheme The URL scheme to match for the authentication callback. + * @param onComplete The callback to run with the authentication result. */ - suspend fun authenticate( + internal suspend fun authenticate( activity: ComponentActivity, - url: Uri, + url: String, callbackUrlScheme: String, onComplete: ((Result<String>) -> Unit)? ) { @@ -56,13 +52,14 @@ internal class WebAuthComponent { intent.intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) intent.intent.putExtra("android.support.customtabs.extra.KEEP_ALIVE", keepAliveIntent) - intent.launchUrl(activity, url) + intent.launchUrl(activity, Uri.parse(url)) + // Add this as a lifecycle observer so that we know when the user returns to the app. activity.runOnUiThread { activity.lifecycle.addObserver(this) } - // Need to dirty poll block so execution doesn't continue at the callsite of this function + // Poll until the authentication has been resumed. suspended = true while (suspended) { delay(200) @@ -71,30 +68,49 @@ internal class WebAuthComponent { } /** - * Trigger a web auth callback + * Invoke the callback for the provided scheme. * - * Attempts to find a callback for the given [scheme] and if found, invokes it, passing the - * given [url]. Calling this method stops auth suspension, so any calls to [authenticate] - * will continue execution from their suspension points immediately after this method - * is called. + * This method ends the suspension, allowing any waiting coroutines to resume. * - * @param scheme The scheme to match to a callback's key - * @param url The url received through intent data from the [CallbackActivity] + * @param scheme The callback scheme key. + * @param url The URL provided in the callback. */ - fun onCallback(scheme: String, url: Uri) { - callbacks.remove(scheme)?.invoke( - Result.success(url.toString()) - ) + internal fun onCallback(scheme: String, url: String) { + callbacks.remove(scheme)?.invoke(Result.success(url)) suspended = false } private fun cleanUp() { - callbacks.forEach { (_, danglingResultCallback) -> - danglingResultCallback?.invoke( - Result.failure(IllegalStateException("User cancelled login")) - ) + callbacks.forEach { (_, callback) -> + callback?.invoke(Result.failure(IllegalStateException("User cancelled login"))) } callbacks.clear() } } + + /** + * Suspend function to perform authentication. + * + * @param url The URL to launch. + * @param callbackUrlScheme The callback URL scheme. + * @param onComplete The callback to invoke with the result. + */ + @Throws(Throwable::class) + internal actual suspend fun authenticate( + url: String, + callbackUrlScheme: String, + onComplete: ((Result<String>) -> Unit)? + ) { + authenticate(activity, url, callbackUrlScheme, onComplete) + } + + /** + * Called when the external callback URL is received. + * + * @param scheme The scheme received. + * @param url The full URL for the callback. + */ + internal actual fun onCallback(scheme: String, url: String) { + WebAuthComponent.onCallback(scheme, url) + } } \ No newline at end of file diff --git a/templates/cli/scoop/appwrite.json.twig b/templates/cli/scoop/appwrite.json.twig index c4ccbaef8..6a007b421 100644 --- a/templates/cli/scoop/appwrite.json.twig +++ b/templates/cli/scoop/appwrite.json.twig @@ -1,30 +1,30 @@ -{ - "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", - "version": "{{ sdk.version }}", - "description": "The Appwrite CLI is a command-line application that allows you to interact with Appwrite and perform server-side tasks using your terminal.", - "homepage": "https://github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}", - "license": "BSD-3-Clause", - "architecture": { - "64bit": { - "url": "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/releases/download/{{ sdk.version }}/{{ language.params.executableName }}-cli-win-x64.exe", - "bin": [ - [ - "{{ language.params.executableName }}-cli-win-x64.exe", - "{{ language.params.executableName|caseLower }}" - ] - ] - }, - "arm64": { - "url": "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/releases/download/{{ sdk.version }}/{{ language.params.executableName }}-cli-win-arm64.exe", - "bin": [ - [ - "{{ language.params.executableName }}-cli-win-arm64.exe", - "{{ language.params.executableName|caseLower }}" - ] - ] - } - }, - "checkver": { - "github": "https://github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}" - } +{ + "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", + "version": "{{ sdk.version }}", + "description": "The Appwrite CLI is a command-line application that allows you to interact with Appwrite and perform server-side tasks using your terminal.", + "homepage": "https://github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}", + "license": "BSD-3-Clause", + "architecture": { + "64bit": { + "url": "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/releases/download/{{ sdk.version }}/{{ language.params.executableName }}-cli-win-x64.exe", + "bin": [ + [ + "{{ language.params.executableName }}-cli-win-x64.exe", + "{{ language.params.executableName|caseLower }}" + ] + ] + }, + "arm64": { + "url": "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/releases/download/{{ sdk.version }}/{{ language.params.executableName }}-cli-win-arm64.exe", + "bin": [ + [ + "{{ language.params.executableName }}-cli-win-arm64.exe", + "{{ language.params.executableName|caseLower }}" + ] + ] + } + }, + "checkver": { + "github": "https://github.com/{{ sdk.gitUserName|url_encode }}/{{ sdk.gitRepoName|url_encode }}" + } } \ No newline at end of file diff --git a/templates/dotnet/Package/Extensions/Extensions.cs.twig b/templates/dotnet/Package/Extensions/Extensions.cs.twig index 10b2b5035..b2b81cd51 100644 --- a/templates/dotnet/Package/Extensions/Extensions.cs.twig +++ b/templates/dotnet/Package/Extensions/Extensions.cs.twig @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using System; using System.Collections; using System.Collections.Generic; diff --git a/templates/kmp/.github/workflows/autoclose.yml b/templates/kmp/.github/workflows/autoclose.yml new file mode 100644 index 000000000..3e2b3cbce --- /dev/null +++ b/templates/kmp/.github/workflows/autoclose.yml @@ -0,0 +1,11 @@ +name: Auto-close External Pull Requests + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + auto_close: + uses: appwrite/.github/.github/workflows/autoclose.yml@main + secrets: + GH_AUTO_CLOSE_PR_TOKEN: ${{ secrets.GH_AUTO_CLOSE_PR_TOKEN }} diff --git a/templates/kmp/.github/workflows/publish.yml b/templates/kmp/.github/workflows/publish.yml new file mode 100644 index 000000000..de6a6b8d3 --- /dev/null +++ b/templates/kmp/.github/workflows/publish.yml @@ -0,0 +1,52 @@ +name: Publish to Maven Central + +on: + release: + types: [released] + +jobs: + publish: + name: Release build and publish + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'temurin' + + - name: Prepare environment + env: + GPG_KEY_CONTENTS: ${{ secrets.GPG_KEY_CONTENTS }} + SIGNING_SECRET_KEY_RING_FILE: ${{ secrets.SIGNING_SECRET_KEY_RING_FILE }} + run: | + git fetch --unshallow + sudo bash -c "echo '$GPG_KEY_CONTENTS' | base64 -d > '$SIGNING_SECRET_KEY_RING_FILE'" + chmod +x ./gradlew + + - name: Build Release Artifacts + run: ./gradlew assemble + + - name: Generate Documentation + run: ./gradlew dokkaHtml + + - name: Publish to Maven Central + run: | + if ${{ contains(github.event.release.tag_name, '-rc') }}; then + echo "Publishing Snapshot Version ${{ github.event.release.tag_name}}" + ./gradlew publishAllPublicationsToSonatypeRepository + else + echo "Publishing Release Version ${{ github.event.release.tag_name}}" + ./gradlew publishAllPublicationsToSonatypeRepository closeAndReleaseSonatypeStagingRepository + fi + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + SIGNING_SECRET_KEY_RING_FILE: ${{ secrets.SIGNING_SECRET_KEY_RING_FILE }} + SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }} + SDK_VERSION: ${{ github.event.release.tag_name }} diff --git a/templates/kmp/.gitignore b/templates/kmp/.gitignore new file mode 100644 index 000000000..f7a9fde78 --- /dev/null +++ b/templates/kmp/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +.idea +.DS_Store +build +captures +.externalNativeBuild +.cxx +local.properties +xcuserdata diff --git a/templates/kmp/CHANGELOG.md.twig b/templates/kmp/CHANGELOG.md.twig new file mode 100644 index 000000000..e87fcf8f2 --- /dev/null +++ b/templates/kmp/CHANGELOG.md.twig @@ -0,0 +1 @@ +{{sdk.changelog}} diff --git a/templates/kmp/LICENSE.md.twig b/templates/kmp/LICENSE.md.twig new file mode 100644 index 000000000..ce6435c38 --- /dev/null +++ b/templates/kmp/LICENSE.md.twig @@ -0,0 +1 @@ +{{sdk.licenseContent | raw}} diff --git a/templates/kmp/README.md.twig b/templates/kmp/README.md.twig new file mode 100644 index 000000000..9e3ddc69c --- /dev/null +++ b/templates/kmp/README.md.twig @@ -0,0 +1,76 @@ +# {{ spec.title }} {{sdk.name}} SDK + + + + +[](https://travis-ci.com/appwrite/sdk-generator) +{% if sdk.twitterHandle %} +[](https://twitter.com/{{ sdk.twitterHandle }}) +{% endif %} +{% if sdk.discordChannel %} +[]({{ sdk.discordUrl }}) +{% endif %} +{% if sdk.warning %} + +{{ sdk.warning|raw }} +{% endif %} + +{{ sdk.description }} + +{% if sdk.logo %} + +{% endif %} + +## Installation + +### Gradle + +Appwrite's Android SDK is hosted on Maven Central. In order to fetch the Appwrite SDK, add this to your root level `build.gradle(.kts)` file: + +```groovy +repositories { + mavenCentral() +} +``` + +If you would like to fetch our SNAPSHOT releases, you need to add the SNAPSHOT maven repository to your `build.gradle(.kts)`: + +```groovy +repositories { + maven { + url "https://s01.oss.sonatype.org/content/repositories/snapshots/" + } +} +``` + +Next, add the dependency to your project's `build.gradle(.kts)` file: + +```groovy +implementation("{{ sdk.namespace | caseDot }}:{{ sdk.gitRepoName | caseDash }}:{{ sdk.version }}") +``` + +### Maven +Add this to your project's `pom.xml` file: + +```xml +<dependencies> + <dependency> + <groupId>{{ sdk.namespace | caseDot }}</groupId> + <artifactId>{{ sdk.gitRepoName | caseDash }}</artifactId> + <version>{{sdk.version}}</version> + </dependency> +</dependencies> +``` + +{% if sdk.gettingStarted %} + +{{ sdk.gettingStarted|raw }} +{% endif %} + +## Contribution + +This library is auto-generated by Appwrite custom [SDK Generator](https://github.com/appwrite/sdk-generator). To learn more about how you can help us improve this SDK, please check the [contribution guide](https://github.com/appwrite/sdk-generator/blob/master/CONTRIBUTING.md) before sending a pull-request. + +## License + +Please see the [{{spec.licenseName}} license]({{spec.licenseURL}}) file for more information. diff --git a/templates/kmp/androidApp/build.gradle.kts b/templates/kmp/androidApp/build.gradle.kts new file mode 100644 index 000000000..3d6abb877 --- /dev/null +++ b/templates/kmp/androidApp/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinx.serialization) +} + +android { + namespace = "io.appwrite.android" + compileSdk = 35 + defaultConfig { + applicationId = "io.appwrite.android" + minSdk = 21 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(projects.shared) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.androidx.activity.compose) + debugImplementation(libs.compose.ui.tooling) +} diff --git a/templates/kmp/androidApp/src/main/AndroidManifest.xml b/templates/kmp/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2559fdb2d --- /dev/null +++ b/templates/kmp/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="io.appwrite.android"> + + <application + android:allowBackup="false" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.AppwriteAndroidSDK" + android:usesCleartextTraffic="true"> + + <activity android:name=".MainActivity" android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + + <activity android:name="io.appwrite.views.CallbackActivity" android:exported="true"> + <intent-filter android:label="android_web_auth"> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data android:scheme="appwrite-callback-6070749e6acd4" /> + </intent-filter> + </activity> + + <service android:name=".services.MessagingService" android:exported="false"> + <intent-filter> + <action android:name="com.google.firebase.MESSAGING_EVENT" /> + </intent-filter> + </service> + + </application> + +</manifest> diff --git a/templates/kmp/androidApp/src/main/java/io/package/android/MainActivity.kt.twig b/templates/kmp/androidApp/src/main/java/io/package/android/MainActivity.kt.twig new file mode 100644 index 000000000..1dc2a057c --- /dev/null +++ b/templates/kmp/androidApp/src/main/java/io/package/android/MainActivity.kt.twig @@ -0,0 +1,23 @@ +package {{ sdk.namespace | caseDot }}.android + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import androidx.fragment.app.add +import androidx.fragment.app.commit +import {{ sdk.namespace | caseDot }}.android.ui.accounts.AccountsFragment +import {{ sdk.namespace | caseDot }}.android.utils.Client + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + Client.create(applicationContext) + + if (savedInstanceState == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add<AccountsFragment>(R.id.fragment_container_view) + } + } + } +} diff --git a/templates/kmp/androidApp/src/main/java/io/package/android/services/MessagingService.kt.twig b/templates/kmp/androidApp/src/main/java/io/package/android/services/MessagingService.kt.twig new file mode 100644 index 000000000..c4248f96a --- /dev/null +++ b/templates/kmp/androidApp/src/main/java/io/package/android/services/MessagingService.kt.twig @@ -0,0 +1,37 @@ +package {{ sdk.namespace | caseDot }}.android.services + +import com.google.firebase.messaging.FirebaseMessagingService +import {{ sdk.namespace | caseDot }}.ID +import {{ sdk.namespace | caseDot }}.services.Account +import kotlinx.coroutines.runBlocking + +class MessagingService : FirebaseMessagingService() { + + companion object { + var account: Account? = null + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + + val prefs = getSharedPreferences("example", MODE_PRIVATE) + + prefs.edit().putString("fcmToken", token).apply() + + if (account == null) { + return + } + + val targetId = prefs.getString("targetId", null) + + runBlocking { + if (targetId == null) { + val target = account!!.createPushTarget(ID.unique(), token) + + prefs.edit().putString("targetId", target.id).apply() + } else { + account!!.updatePushTarget(targetId, token) + } + } + } +} diff --git a/templates/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsFragment.kt.twig b/templates/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsFragment.kt.twig new file mode 100644 index 000000000..ab08a813d --- /dev/null +++ b/templates/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsFragment.kt.twig @@ -0,0 +1,85 @@ +package {{ sdk.namespace | caseDot }}.android.ui.accounts + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import {{ sdk.namespace | caseDot }}.android.R +import {{ sdk.namespace | caseDot }}.android.databinding.FragmentAccountBinding + + +class AccountsFragment : Fragment() { + + private lateinit var binding: FragmentAccountBinding + private val viewModel: AccountsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater , + container: ViewGroup? , + savedInstanceState: Bundle? + ): View { + binding = DataBindingUtil.inflate( + inflater, + R.layout.fragment_account, + container, + false + ) + binding.lifecycleOwner = viewLifecycleOwner + + binding.login.setOnClickListener{ + viewModel.onLogin( + binding.email.text.toString(), + binding.password.text.toString(), + context + ?.getSharedPreferences("example", Context.MODE_PRIVATE) + ?.getString("fcmToken", null) ?: "" + ) + } + binding.signup.setOnClickListener{ + viewModel.onSignup( + binding.email.text.toString(), + binding.password.text.toString(), + binding.name.text.toString() + ) + } + binding.getUser.setOnClickListener{ + viewModel.getUser() + } + binding.oAuth.setOnClickListener{ + viewModel.oAuthLogin(activity as ComponentActivity) + } + binding.logout.setOnClickListener{ + viewModel.logout() + } + + viewModel.error.observe(viewLifecycleOwner) { event -> + event?.getContentIfNotHandled()?.let { + Toast.makeText(requireContext(), it.message, Toast.LENGTH_SHORT).show() + } + } + + viewModel.response.observe(viewLifecycleOwner) { event -> + event?.getContentIfNotHandled()?.let { + binding.responseTV.setText(it) + } + } + + viewModel.target.observe(viewLifecycleOwner) { event -> + event?.getContentIfNotHandled()?.let { + context + ?.getSharedPreferences("example", Context.MODE_PRIVATE) + ?.edit() + ?.putString("targetId", it.id) + ?.apply() + } + } + + return binding.root + } +} diff --git a/templates/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsViewModel.kt.twig b/templates/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsViewModel.kt.twig new file mode 100644 index 000000000..b841b6636 --- /dev/null +++ b/templates/kmp/androidApp/src/main/java/io/package/android/ui/accounts/AccountsViewModel.kt.twig @@ -0,0 +1,116 @@ +package {{ sdk.namespace | caseDot }}.android.ui.accounts + +import androidx.activity.ComponentActivity +import androidx.lifecycle.* +import {{ sdk.namespace | caseDot }}.ID +import {{ sdk.namespace | caseDot }}.android.services.MessagingService +import {{ sdk.namespace | caseDot }}.android.utils.Client.client +import {{ sdk.namespace | caseDot }}.android.utils.Event +import {{ sdk.namespace | caseDot }}.enums.OAuthProvider +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception +import {{ sdk.namespace | caseDot }}.extensions.toJson +import {{ sdk.namespace | caseDot }}.models.Target +import {{ sdk.namespace | caseDot }}.services.Account +import kotlinx.coroutines.launch + +class AccountsViewModel : ViewModel() { + + private val _error = MutableLiveData<Event<Exception>>().apply { value = null } + val error: LiveData<Event<Exception>> = _error + + private val _response = MutableLiveData<Event<String>>().apply { value = null } + val response: LiveData<Event<String>> = _response + + private val _target = MutableLiveData<Event<Target>>().apply { value = null } + val target: LiveData<Event<Target>> = _target + + private val account by lazy { + val account = Account(client) + + MessagingService.account = account + + account + } + + fun onLogin( + email: String, + password: String, + token: String?, + ) { + viewModelScope.launch { + try { + val session = account.createEmailPasswordSession( + email, + password + ) + + if (token != null) { + val target = account.createPushTarget(ID.unique(), token) + + _target.postValue(Event(target)) + } + + _response.postValue(Event(session.toJson())) + } catch (e: AppwriteException) { + _error.postValue(Event(e)) + } + } + + } + + fun onSignup(email: String, password: String, name: String) { + viewModelScope.launch { + try { + val user = account.create( + ID.unique(), + email, + password, + name + ) + _response.postValue(Event(user.toJson())) + } catch (e: AppwriteException) { + _error.postValue(Event(e)) + } + } + + } + + fun oAuthLogin(activity: ComponentActivity) { + viewModelScope.launch { + try { + account.createOAuth2Session( + activity, + OAuthProvider.FACEBOOK, + "appwrite-callback-6070749e6acd4://demo.appwrite.io/auth/oauth2/success", + "appwrite-callback-6070749e6acd4://demo.appwrite.io/auth/oauth2/failure" + ) + } catch (e: Exception) { + _error.postValue(Event(e)) + } catch (e: AppwriteException) { + _error.postValue(Event(e)) + } + } + } + + fun getUser() { + viewModelScope.launch { + try { + val user = account.get() + _response.postValue(Event(user.toJson())) + } catch (e: AppwriteException) { + _error.postValue(Event(e)) + } + } + } + + fun logout() { + viewModelScope.launch { + try { + val result = account.deleteSession("current") + _response.postValue(Event(result.toJson())) + } catch (e: AppwriteException) { + _error.postValue(Event(e)) + } + } + } +} diff --git a/templates/kmp/androidApp/src/main/java/io/package/android/utils/Client.kt.twig b/templates/kmp/androidApp/src/main/java/io/package/android/utils/Client.kt.twig new file mode 100644 index 000000000..f8ebf4180 --- /dev/null +++ b/templates/kmp/androidApp/src/main/java/io/package/android/utils/Client.kt.twig @@ -0,0 +1,15 @@ +package {{ sdk.namespace | caseDot }}.android.utils + +import android.content.Context +import io.appwrite.Client + +object Client { + lateinit var client : Client + + fun create(context: Context) { + client = Client(context) + .setEndpoint("http://192.168.4.24/v1") + .setProject("65a8e2b4632c04b1f5da") + .setSelfSigned(true) + } +} diff --git a/templates/kmp/androidApp/src/main/java/io/package/android/utils/Event.kt.twig b/templates/kmp/androidApp/src/main/java/io/package/android/utils/Event.kt.twig new file mode 100644 index 000000000..a43a58886 --- /dev/null +++ b/templates/kmp/androidApp/src/main/java/io/package/android/utils/Event.kt.twig @@ -0,0 +1,27 @@ +package {{ sdk.namespace | caseDot }}.android.utils + +/** + * Used as a wrapper for data that is exposed via a LiveData that represents an event. + */ +open class Event<out T>(private val content: T) { + + var hasBeenHandled = false + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} diff --git a/templates/kmp/androidApp/src/main/res/drawable/ic_launcher_background.xml b/templates/kmp/androidApp/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#3DDC84" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> diff --git a/templates/kmp/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/templates/kmp/androidApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..7706ab9e6 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="85.84757" + android:endY="92.4963" + android:startX="42.9492" + android:startY="49.59793" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" + android:strokeWidth="1" + android:strokeColor="#00000000" /> +</vector> diff --git a/templates/kmp/androidApp/src/main/res/layout/activity_main.xml b/templates/kmp/androidApp/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..00cef8106 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/layout/activity_main.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".MainActivity"> + + <androidx.fragment.app.FragmentContainerView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/fragment_container_view" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/templates/kmp/androidApp/src/main/res/layout/fragment_account.xml b/templates/kmp/androidApp/src/main/res/layout/fragment_account.xml new file mode 100644 index 000000000..4173be134 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/layout/fragment_account.xml @@ -0,0 +1,125 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + <ScrollView + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/constraint_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp"> + + <EditText + android:id="@+id/responseTV" + android:layout_width="match_parent" + android:layout_height="200dp" + android:background="@null" + android:enabled="true" + android:fadeScrollbars="false" + android:focusable="true" + android:longClickable="true" + android:scrollbars="vertical" + android:textIsSelectable="true" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <EditText + android:id="@+id/email" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:hint="Email" + android:inputType="textEmailAddress" + android:text="test@test.com" + app:layout_constraintStart_toStartOf="@id/responseTV" + app:layout_constraintTop_toBottomOf="@id/responseTV" /> + + + <EditText + android:id="@+id/password" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:hint="password" + android:inputType="textPassword" + android:text="testtest" + app:layout_constraintStart_toStartOf="@id/email" + app:layout_constraintTop_toBottomOf="@id/email" /> + + <EditText + android:id="@+id/name" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:hint="name" + android:text="Tester" + android:inputType="text" + app:layout_constraintStart_toStartOf="@id/password" + app:layout_constraintTop_toBottomOf="@id/password" /> + + <Button + android:id="@+id/login" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:text="Login" + app:layout_constraintEnd_toStartOf="@+id/signup" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintHorizontal_chainStyle="spread" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/name" /> + + <Button + android:id="@+id/signup" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:text="Signup" + app:layout_constraintEnd_toStartOf="@id/getUser" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toEndOf="@+id/login" + app:layout_constraintTop_toBottomOf="@+id/name" /> + + <Button + android:id="@+id/getUser" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:text="Get User" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toEndOf="@+id/signup" + app:layout_constraintTop_toBottomOf="@+id/name" /> + + <Button + android:id="@+id/oAuth" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:text="Login with Facebook" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@+id/logout" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintHorizontal_chainStyle="spread" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/signup" /> + + <Button + android:id="@+id/logout" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Logout" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toEndOf="@+id/oAuth" + app:layout_constraintTop_toTopOf="@id/oAuth" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + </ScrollView> + +</layout> diff --git a/templates/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/templates/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/templates/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/templates/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/templates/kmp/androidApp/src/main/res/values/colors.xml b/templates/kmp/androidApp/src/main/res/values/colors.xml new file mode 100644 index 000000000..ca1931bca --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="purple_200">#FFBB86FC</color> + <color name="purple_500">#FF6200EE</color> + <color name="purple_700">#FF3700B3</color> + <color name="teal_200">#FF03DAC5</color> + <color name="teal_700">#FF018786</color> + <color name="black">#FF000000</color> + <color name="white">#FFFFFFFF</color> +</resources> diff --git a/templates/kmp/androidApp/src/main/res/values/strings.xml b/templates/kmp/androidApp/src/main/res/values/strings.xml new file mode 100644 index 000000000..01da07727 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ +<resources> + <string name="app_name">Appwrite Android SDK</string> +</resources> diff --git a/templates/kmp/androidApp/src/main/res/values/themes.xml b/templates/kmp/androidApp/src/main/res/values/themes.xml new file mode 100644 index 000000000..22cd167a5 --- /dev/null +++ b/templates/kmp/androidApp/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ +<resources xmlns:tools="http://schemas.android.com/tools"> + <!-- Base application theme. --> + <style name="Theme.AppwriteAndroidSDK" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> + <!-- Primary brand color. --> + <item name="colorPrimary">@color/purple_500</item> + <item name="colorPrimaryVariant">@color/purple_700</item> + <item name="colorOnPrimary">@color/white</item> + <!-- Secondary brand color. --> + <item name="colorSecondary">@color/teal_200</item> + <item name="colorSecondaryVariant">@color/teal_700</item> + <item name="colorOnSecondary">@color/black</item> + <!-- Status bar color. --> + <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item> + <!-- Customize your theme here. --> + </style> +</resources> diff --git a/templates/kmp/build.gradle.kts.twig b/templates/kmp/build.gradle.kts.twig new file mode 100644 index 000000000..1cd355e8d --- /dev/null +++ b/templates/kmp/build.gradle.kts.twig @@ -0,0 +1,9 @@ +plugins { + //trick: for the same plugin versions in all sub-modules + alias(libs.plugins.androidApplication).apply(false) + alias(libs.plugins.androidLibrary).apply(false) + alias(libs.plugins.kotlinAndroid).apply(false) + alias(libs.plugins.kotlinMultiplatform).apply(false) + alias(libs.plugins.compose.compiler).apply(false) + alias(libs.plugins.kotlinx.serialization).apply(false) +} diff --git a/templates/kmp/gradle.properties b/templates/kmp/gradle.properties new file mode 100644 index 000000000..49fa639a9 --- /dev/null +++ b/templates/kmp/gradle.properties @@ -0,0 +1,12 @@ +#Gradle +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.caching=true +org.gradle.configuration-cache=true +#Kotlin +kotlin.code.style=official +#Android +android.useAndroidX=true +android.nonTransitiveRClass=true +xcodeproj=./iosApp +kotlin.mpp.enableCInteropCommonization=true +kotlin.native.binary.objcExportSuspendFunctionLaunchThreadRestriction=none diff --git a/templates/kmp/gradle/libs.versions.toml b/templates/kmp/gradle/libs.versions.toml new file mode 100644 index 000000000..ca31ceb8c --- /dev/null +++ b/templates/kmp/gradle/libs.versions.toml @@ -0,0 +1,74 @@ +[versions] +agp = "8.7.3" +appcompat = "1.7.0" +coreKtx = "1.15.0" +datastore = "1.1.2" +espressoCore = "3.6.1" +gson = "2.10.1" +junit = "4.13.2" +junitVersion = "1.2.1" +kotlin = "2.1.0" +ktor = "3.0.3" +activityKtx = "1.10.0" +browser = "1.8.0" +kotlinxCoroutines = "1.10.1" +kotlinxDatetime = "0.6.1" +kotlinxSerializationJson = "1.7.3" +napier = "2.7.1" +lifecycleLivedataKtx = "2.8.7" +navigationUiKtx = "2.8.7" +firebaseCrashlyticsBuildtools = "3.0.3" +okio = "3.10.2" +robolectric = "4.14.1" +androidx-test = "1.6.1" +coroutines-test = "1.10.1" +test-runner = "1.6.2" + + +[libraries] +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "test-runner" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } +androidx-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "junitVersion" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleLivedataKtx" } +androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigationUiKtx" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigationUiKtx" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +napier = { module = "io.github.aakira:napier", version.ref = "napier" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/templates/kmp/gradle/wrapper/gradle-wrapper.jar b/templates/kmp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e708b1c02 Binary files /dev/null and b/templates/kmp/gradle/wrapper/gradle-wrapper.jar differ diff --git a/templates/kmp/gradle/wrapper/gradle-wrapper.properties b/templates/kmp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..3a77fdf89 --- /dev/null +++ b/templates/kmp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Nov 27 15:47:26 PST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/templates/kmp/gradlew b/templates/kmp/gradlew new file mode 100644 index 000000000..4f906e0c8 --- /dev/null +++ b/templates/kmp/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/templates/kmp/gradlew.bat b/templates/kmp/gradlew.bat new file mode 100644 index 000000000..107acd32c --- /dev/null +++ b/templates/kmp/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/templates/kmp/settings.gradle.kts b/templates/kmp/settings.gradle.kts new file mode 100644 index 000000000..d7a7d2d4f --- /dev/null +++ b/templates/kmp/settings.gradle.kts @@ -0,0 +1,22 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { + url = uri("https://jitpack.io") + } + } +} + +rootProject.name = "Appwrite_KMP_SDK" +include(":androidApp") +include(":shared") diff --git a/templates/kmp/shared/build.gradle.kts.twig b/templates/kmp/shared/build.gradle.kts.twig new file mode 100644 index 000000000..773cc036f --- /dev/null +++ b/templates/kmp/shared/build.gradle.kts.twig @@ -0,0 +1,231 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinx.serialization) + id("org.jetbrains.kotlinx.atomicfu") version "0.26.0" + id("maven-publish") + id("signing") +} + +ext { + set("PUBLISH_GROUP_ID", "{{ sdk.namespace | caseDot }}") + set("PUBLISH_ARTIFACT_ID", "{{ sdk.gitRepoName | caseDash }}") + set("PUBLISH_VERSION", System.getenv("SDK_VERSION")) + set("POM_URL", "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}") + set("POM_SCM_URL", "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}") + set("POM_ISSUE_URL", "https://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}/issues") + set("POM_DESCRIPTION", "{{ sdk.description }}") + set("POM_LICENSE_URL", "https://opensource.org/licenses/GPL-3.0") + set("POM_LICENSE_NAME", "GPL-3.0") + set("POM_DEVELOPER_ID", "{{ sdk.gitUserName }}") + set("POM_DEVELOPER_NAME", "{{ spec.contactName }}") + set("POM_DEVELOPER_EMAIL", "{{ spec.contactEmail }}") + set("GITHUB_SCM_CONNECTION", "scm:git:git://github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName }}.git") +} + +version = project.ext["PUBLISH_VERSION"].toString() +group = project.ext["PUBLISH_GROUP_ID"].toString() + + +kotlin { + jvm() + + androidTarget { + publishLibraryVariants("release", "debug") + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + } + } + + metadata { + compilations.all { + val compilationName = name + compileTaskProvider.configure { + if (this is KotlinCompileCommon) { + moduleName = "${project.group}:${project.name}_$compilationName" + } + } + } + } + + targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> { + binaries.all { + // Configure native binary compilation + freeCompilerArgs += listOf( + "-Xallocator=mimalloc", + ) + } + } + + iosX64 { + // Create a framework binary with additional options + binaries.framework { + baseName = "shared" + isStatic = true + binaryOption("bundleId", "io.appwrite.shared") + } + withSourcesJar(publish = false) + } + iosArm64 { + binaries.framework { + baseName = "shared" + isStatic = true + binaryOption("bundleId", "io.appwrite.shared") + } + withSourcesJar(publish = false) + } + iosSimulatorArm64 { + binaries.framework { + baseName = "shared" + isStatic = true + binaryOption("bundleId", "io.appwrite.shared") + } + withSourcesJar(publish = false) + } + + sourceSets { + jvmMain.dependencies { + implementation(libs.ktor.client.java) + } + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlin.reflect) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.websockets) + implementation(libs.okio) + api(libs.kotlinx.serialization.json) + api(libs.ktor.serialization.kotlinx.json) + api(libs.napier) + } + + androidMain.dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.firebase.messaging) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.logging) + implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) + implementation(libs.gson) + implementation(project.dependencies.platform("com.google.firebase:firebase-bom:33.6.0")) + } + + iosMain.dependencies { + implementation(libs.jetbrains.kotlinx.coroutines.core) + implementation(libs.ktor.client.darwin) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + + androidUnitTest.dependencies { + implementation(libs.junit) + implementation(libs.androidx.test.core) + implementation(libs.androidx.junit) + implementation(libs.androidx.test.runner) + implementation(libs.robolectric) + implementation(libs.ktor.client.mock) + } + + androidInstrumentedTest.dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.espresso.core) + } + } +} + +publishing { + publications.withType<MavenPublication> { + artifactId = if (name != "kotlinMultiplatform") { + // Append the target name to the artifactId so each publication is unique. + "${project.ext["PUBLISH_ARTIFACT_ID"].toString()}-$name" + } else { + project.ext["PUBLISH_ARTIFACT_ID"].toString() + } + + pom { + name.set(project.ext["PUBLISH_ARTIFACT_ID"].toString()) + description.set(project.ext["POM_DESCRIPTION"].toString()) + url.set(project.ext["POM_URL"].toString()) + + licenses { + license { + name.set(project.ext["POM_LICENSE_NAME"].toString()) + url.set(project.ext["POM_LICENSE_URL"].toString()) + } + } + + developers { + developer { + id.set(project.ext["POM_DEVELOPER_ID"].toString()) + name.set(project.ext["POM_DEVELOPER_NAME"].toString()) + email.set(project.ext["POM_DEVELOPER_EMAIL"].toString()) + } + } + + scm { + connection.set(project.ext["GITHUB_SCM_CONNECTION"].toString()) + url.set(project.ext["POM_SCM_URL"].toString()) + } + } + } + + repositories { + maven { + name = "sonatype" + url = uri( + if (version.toString().endsWith("SNAPSHOT")) { + "https://s01.oss.sonatype.org/content/repositories/snapshots/" + } else { + "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" + } + ) + credentials { + username = System.getenv("OSSRH_USERNAME") + password = System.getenv("OSSRH_PASSWORD") + } + } + } +} + +android { + namespace = "io.appwrite" + compileSdk = 35 + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + testOptions { + unitTests { + isIncludeAndroidResources = true + all { it.useJUnit() } + } + } +} + +dependencies { + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.browser) + implementation(libs.androidx.espresso.core) + implementation(libs.firebase.crashlytics.buildtools) +} \ No newline at end of file diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/AllCertsTrustManager.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/AllCertsTrustManager.kt.twig new file mode 100644 index 000000000..76bd8c6ad --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/AllCertsTrustManager.kt.twig @@ -0,0 +1,18 @@ +package {{ sdk.namespace | caseDot }} + +import android.annotation.SuppressLint +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager + +@SuppressLint("TrustAllX509TrustManager", "CustomX509TrustManager") +class AllCertsTrustManager : X509TrustManager { + override fun checkClientTrusted(p0: Array<out X509Certificate>?, p1: String?) { + // Empty implementation to trust all clients + } + + override fun checkServerTrusted(p0: Array<out X509Certificate>?, p1: String?) { + // Empty implementation to trust all servers + } + + override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray() +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/Client.android.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/Client.android.kt.twig new file mode 100644 index 000000000..707c9f822 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/Client.android.kt.twig @@ -0,0 +1,59 @@ +package {{ sdk.namespace | caseDot }} + +import android.content.Context +import android.content.pm.PackageManager +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import {{ sdk.namespace | caseDot }}.cookies.stores.DataStoreCookieStorage +import {{ sdk.namespace | caseDot }}.cookies.stores.DataStoreManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import okio.Path.Companion.toPath + +actual class Client constructor( + private val context: Context, + endpoint: String = "{{spec.endpoint}}", + endpointRealtime: String? = null, + selfSigned: Boolean = false, +) : BaseClient<Client>(endpoint, endpointRealtime) { + actual override val coroutineContext = Job() + Dispatchers.Default + + private val dataStoreManager = DataStoreManager( + PreferenceDataStoreFactory.createWithPath ( + produceFile = { context.filesDir.resolve("appwriteCookies.preferences_pb").absolutePath.toPath() } + )) + val dataStoreCookieStorage = DataStoreCookieStorage(dataStoreManager) + + private val appVersion by lazy { + try { + val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) + return@lazy pInfo.versionName + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + return@lazy "" + } + } + + init { + + httpClient = createHttpClient(selfSigned, dataStoreCookieStorage) + headers = mutableMapOf( + "content-type" to "application/json", + "origin" to "{{ spec.title | caseLower }}-android://${context.packageName}", + "user-agent" to "${context.packageName}/${appVersion}, ${System.getProperty("http.agent")}", + "x-sdk-name" to "{{ sdk.name }}", + "x-sdk-platform" to "{{ sdk.platform }}", + "x-sdk-language" to "{{ language.name | caseLower }}", + "x-sdk-version" to "{{ sdk.version }}"{% if spec.global.defaultHeaders | length > 0 %},{% endif %} + + {% for key,header in spec.global.defaultHeaders %} + "{{ key | caseLower }}" to "{{ header }}"{% if not loop.last %},{% endif %} + {% endfor %} + + ) + } + + actual fun setSelfSigned(value: Boolean): Client { + httpClient = createHttpClient(value, dataStoreCookieStorage) + return this + } +} \ No newline at end of file diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/HttpClientConfig.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/HttpClientConfig.kt.twig new file mode 100644 index 000000000..499b2c1f9 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/HttpClientConfig.kt.twig @@ -0,0 +1,63 @@ +package {{ sdk.namespace | caseDot }} + +import {{ sdk.namespace | caseDot }}.cookies.stores.DataStoreCookieStorage +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.pingInterval +import io.ktor.serialization.kotlinx.json.json +import java.security.KeyStore +import java.security.SecureRandom +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import kotlin.time.Duration.Companion.seconds + +fun createHttpClient(selfSigned: Boolean, dataStoreCookieStorage: DataStoreCookieStorage) = HttpClient(OkHttp) { + install(HttpCookies) { + storage = dataStoreCookieStorage + } + install(WebSockets) { + pingInterval = 30.seconds + + } + install(HttpTimeout) { + requestTimeoutMillis = 60000 + connectTimeoutMillis = 30000 + socketTimeoutMillis = 30000 + } + install(ContentNegotiation) { + json(io.appwrite.extensions.json) + } + + if (selfSigned) { + engine { + config { + val trustManager = AllCertsTrustManager() + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + sslSocketFactory(sslContext.socketFactory, trustManager) + hostnameVerifier { _, _ -> true } + } + } + } else { + engine { + config { + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, trustManagerFactory.trustManagers, SecureRandom()) + } + sslSocketFactory( + sslContext.socketFactory, + trustManagerFactory.trustManagers[0] as X509TrustManager + ) + } + } + } +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/KeepAliveService.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/KeepAliveService.kt.twig new file mode 100644 index 000000000..083d6bef3 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/KeepAliveService.kt.twig @@ -0,0 +1,13 @@ +package {{ sdk.namespace | caseDot }} + +import android.app.Service +import android.content.Intent +import android.os.Binder + +internal class KeepAliveService: Service() { + companion object { + val binder = Binder() + } + + override fun onBind(intent: Intent) = binder +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/WebAuthComponent.android.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/WebAuthComponent.android.kt.twig new file mode 100644 index 000000000..70fd11347 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/WebAuthComponent.android.kt.twig @@ -0,0 +1,91 @@ +package {{ sdk.namespace | caseDot }} + +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.browser.customtabs.CustomTabsIntent +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.delay +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.forEach +import kotlin.collections.mutableMapOf +import kotlin.collections.set + +/** + * Used to authenticate with external OAuth2 providers. Launches browser windows and handles + * suspension until the user completes the process or otherwise returns to the app. + */ +actual class WebAuthComponent { + actual companion object : DefaultLifecycleObserver { + private var suspended = false + private val callbacks = mutableMapOf<String, ((Result<String>) -> Unit)?>() + + override fun onResume(owner: LifecycleOwner) { + // When the activity resumes, end the suspension so that the caller can continue. + suspended = false + } + + /** + * Authenticate session using OAuth2. + * + * Launches a Chrome Custom Tab from the provided activity to open the given URL. + * Once the user returns to the app (resuming the activity), the provided callback is invoked. + * + * @param activity The activity used to launch the browser and observe lifecycle events. + * @param url The URL to open. + * @param callbackUrlScheme The URL scheme to match for the authentication callback. + * @param onComplete The callback to run with the authentication result. + */ + internal suspend fun authenticate( + activity: ComponentActivity, + url: String, + callbackUrlScheme: String, + onComplete: ((Result<String>) -> Unit)? + ) { + val intent = CustomTabsIntent.Builder().build() + val keepAliveIntent = Intent(activity, KeepAliveService::class.java) + + callbacks[callbackUrlScheme] = onComplete + + intent.intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + intent.intent.putExtra("android.support.customtabs.extra.KEEP_ALIVE", keepAliveIntent) + intent.launchUrl(activity, Uri.parse(url)) + + // Add this as a lifecycle observer so that we know when the user returns to the app. + activity.runOnUiThread { + activity.lifecycle.addObserver(this) + } + + // Poll until the authentication has been resumed. + suspended = true + while (suspended) { + delay(200) + } + cleanUp() + } + + /** + * Invoke the callback for the provided scheme. + * + * This method ends the suspension, allowing any waiting coroutines to resume. + * + * @param scheme The callback scheme key. + * @param url The URL provided in the callback. + */ + actual fun onCallback(scheme: String, url: String) { + callbacks.remove(scheme)?.invoke(Result.success(url)) + suspended = false + } + + private fun cleanUp() { + callbacks.forEach { (_, callback) -> + callback?.invoke(Result.failure(IllegalStateException("User cancelled login"))) + } + callbacks.clear() + } + + actual fun handleIncomingCookie(url: String) {} + } +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/SerializableCookie.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/SerializableCookie.kt.twig new file mode 100644 index 000000000..8cff6e62a --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/SerializableCookie.kt.twig @@ -0,0 +1,38 @@ +package {{ sdk.namespace | caseDot }}.cookies + +import io.ktor.http.Cookie +import kotlinx.serialization.Serializable + +@Serializable +data class SerializableCookie( + val name: String, + val value: String, + val domain: String? = null, + val path: String = "/", + val maxAge: Int? = null, + val secure: Boolean = false, + val httpOnly: Boolean = false, + val expiration: Long = System.currentTimeMillis() + (maxAge?.times(1000L) ?: 2592000000L) +) { + fun toCookie(): Cookie = Cookie( + name = name, + value = value, + domain = domain, + path = path, + maxAge = maxAge, + secure = secure, + httpOnly = httpOnly + ) + + companion object { + fun fromCookie(cookie: Cookie): SerializableCookie = SerializableCookie( + name = cookie.name, + value = cookie.value, + domain = cookie.domain, + path = cookie.path ?: "/", + maxAge = cookie.maxAge, + secure = cookie.secure, + httpOnly = cookie.httpOnly + ) + } +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreCookieStorage.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreCookieStorage.kt.twig new file mode 100644 index 000000000..a9d3a6ab2 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreCookieStorage.kt.twig @@ -0,0 +1,98 @@ +package {{ sdk.namespace | caseDot }}.cookies.stores + +import {{ sdk.namespace | caseDot }}.cookies.SerializableCookie +import {{ sdk.namespace | caseDot }}.extensions.json +import io.ktor.client.plugins.cookies.CookiesStorage +import io.ktor.http.Cookie +import io.ktor.http.Url +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.builtins.ListSerializer + +class DataStoreCookieStorage(private val dataStoreManager: DataStoreManager) : CookiesStorage { + private val storageMutex = Mutex() + private suspend fun loadCookies(): List<SerializableCookie> { + val jsonString = dataStoreManager.read() + return if (jsonString != null) { + try { + json.decodeFromString(ListSerializer(SerializableCookie.serializer()), jsonString) + } catch (e: Exception) { + throw Exception("Error parsing cookies from DataStore", e) + } + } else { + emptyList() + } + } + + private suspend fun saveCookies(cookies: List<SerializableCookie>) { + val jsonString = + json.encodeToString(ListSerializer(SerializableCookie.serializer()), cookies) + dataStoreManager.write(jsonString) + } + + private fun isNotExpired(cookie: SerializableCookie): Boolean { + return cookie.expiration.let { System.currentTimeMillis() < it } + } + + private fun domainMatches(cookieDomain: String?, requestHost: String?): Boolean { + val cd = cookieDomain?.lowercase() + val host = requestHost?.lowercase() + return if (cd != null && host != null) { + if (cd.startsWith(".")) { + host == cd.substring(1) || host.endsWith(cd) + } else if (requestHost.startsWith(".")){ + host == host.substring(1) || cd.endsWith(cd) + } else { + host == cd + } + } else { + false + } + } + + private fun pathMatches(cookiePath: String, requestPath: String): Boolean { + return requestPath.startsWith(cookiePath) + } + + private fun cookieApplies(cookie: SerializableCookie, requestUrl: Url): Boolean { + if (!isNotExpired(cookie)) return false + val cookieDomain = cookie.domain ?: return false + if (!domainMatches(cookieDomain, requestUrl.host)) return false + + val cookiePath = cookie.path + val requestPath = requestUrl.encodedPath.ifEmpty { "/" } + if (!pathMatches(cookiePath, requestPath)) return false + + if (cookie.secure && requestUrl.protocol.name != "https") return false + + return true + } + + override suspend fun get(requestUrl: Url): List<Cookie> = storageMutex.withLock { + return loadCookies() + .filter { cookieApplies(it, requestUrl) } + .map { it.toCookie() } + } + + override suspend fun addCookie(requestUrl: Url, cookie: Cookie): Unit = storageMutex.withLock { + val newCookie = SerializableCookie.fromCookie(cookie) + val cookies = loadCookies().toMutableList() + cookies.removeAll { existing -> + existing.name == newCookie.name && + domainMatches(existing.domain, newCookie.domain) && + existing.path == newCookie.path + } + cookies.add(newCookie) + saveCookies(cookies) + } + + override fun close() { + runBlocking { + storageMutex.withLock { + val validCookies = loadCookies().filter { isNotExpired(it) } + saveCookies(validCookies) + } + } + } +} \ No newline at end of file diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreManager.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreManager.kt.twig new file mode 100644 index 000000000..c971d10c7 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/cookies/stores/DataStoreManager.kt.twig @@ -0,0 +1,24 @@ +package {{ sdk.namespace | caseDot }}.cookies.stores + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first + + +class DataStoreManager(private val dataStore: DataStore<Preferences>) { + + private val key = stringPreferencesKey("cookies") + + suspend fun read(): String? { + val preferences = dataStore.data.first() + return preferences[key] + } + + suspend fun write(value: String) { + dataStore.edit { preferences -> + preferences[key] = value + } + } +} \ No newline at end of file diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig new file mode 100644 index 000000000..3db493af6 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig @@ -0,0 +1,79 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import androidx.activity.ComponentActivity +import {{ sdk.namespace | caseDot }}.WebAuthComponent +import {{ sdk.namespace | caseDot }}.cookies.SerializableCookie +import {{ sdk.namespace | caseDot }}.exceptions.AppwriteException +import {{ sdk.namespace | caseDot }}.webInterface.UrlParser +import io.ktor.http.Url +import kotlinx.coroutines.runBlocking + +{% for service in spec | webAuthServices %} +{% for method in service.methods %} +suspend fun {{ sdk.namespace | caseDot }}.services.{{ service.className | caseUcfirst }}.{{ method.methodName }}( + activity: ComponentActivity, + {%~ for parameter in method.parameters.all %} + {{ parameter.name | caseCamel }}: {{ parameter | typeName }}{%~ if not parameter.required or parameter.nullable %}? = null{%~ endif %}, + {%~ endfor %} + ) { + val apiPath = "{{ method.path }}" + {%~ for parameter in method.parameters.path %} + .replace("{{ '{' ~ parameter.name | caseCamel ~ '}' }}", {{ parameter.name | caseCamel }}{%~ if parameter.enumValues is not empty %}.value{%~ endif %}) + {%~ endfor %} + + val apiParams = mutableMapOf<String, Any?>( + {%~ for parameter in method.parameters.query | merge(method.parameters.body) %} + "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, + {%~ endfor %} + {%~ if method.auth | length > 0 %} + {%~ for node in method.auth %} + {%~ for key,header in node | keys %} + "{{ header | caseLower }}" to client.config["{{ header | caseLower }}"], + {%~ endfor %} + {%~ endfor %} + {%~ endif %} + ) + + val apiQuery = mutableListOf<String>() + apiParams.forEach { + when (it.value) { + null -> return@forEach + is List<*> -> apiQuery.add("${it.key}[]=${it.value.toString()}") + else -> apiQuery.add("${it.key}=${it.value.toString()}") + } + } + + val urlParser = UrlParser() + val fullUrl = "${client.endpoint}${apiPath}?${apiQuery.joinToString("&")}" + val apiUrl = urlParser.parse(fullUrl) + val callbackUrlScheme = "{{ spec.title | caseLower }}-callback-${client.config["project"]}" + + WebAuthComponent.authenticate(activity, apiUrl.toString(), callbackUrlScheme) { + if (it.isFailure) { + throw it.exceptionOrNull()!! + } + + val resultUrl = it.getOrNull()!! + val key = urlParser.getQueryParameter(resultUrl, "key") + val secret = urlParser.getQueryParameter(resultUrl, "secret") + if (key == null || secret == null) { + throw {{ spec.title | caseUcfirst }}Exception("Authentication cookie missing!") + } + + val host = urlParser.getHost(client.endpoint) + val cookie = SerializableCookie( + name = key, + value = secret, + domain = host, + httpOnly = true, + ) + + val requestUrl = Url(client.endpoint) + runBlocking { + client.dataStoreCookieStorage.addCookie(requestUrl, cookie.toCookie()) + } + } +} + +{% endfor %} +{% endfor %} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/models/InputFile.android.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/models/InputFile.android.kt.twig new file mode 100644 index 000000000..de04eb8d6 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/models/InputFile.android.kt.twig @@ -0,0 +1,14 @@ +package {{ sdk.namespace | caseDot }}.models + +import android.os.Build +import androidx.annotation.RequiresApi +import java.net.URLConnection +import java.nio.file.Files +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +actual fun guessMimeType(input: String): String { + val file = File(input) + return Files.probeContentType(file.toPath()) ?: + URLConnection.guessContentTypeFromName(file.name) ?: "" +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/views/CallbackActivity.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/views/CallbackActivity.kt.twig new file mode 100644 index 000000000..368f202f8 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/views/CallbackActivity.kt.twig @@ -0,0 +1,20 @@ +package {{ sdk.namespace | caseDot }}.views + +import android.app.Activity +import android.os.Bundle +import {{ sdk.namespace | caseDot }}.WebAuthComponent + +class CallbackActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val url = intent?.data + val scheme = url?.scheme + if (scheme != null) { + // Found a scheme, try to callback to web auth component. + // Will only succeed if the scheme matches one launched by this sdk. + WebAuthComponent.onCallback(scheme, url.toString()) + } + finish() + } +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/webInterface/AndroidParsedUrl.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/webInterface/AndroidParsedUrl.kt.twig new file mode 100644 index 000000000..cdb2a0ae7 --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/webInterface/AndroidParsedUrl.kt.twig @@ -0,0 +1,7 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +import android.net.Uri + +class AndroidParsedUrl(private val uri: Uri) : ParsedUrl{ + override fun toString(): String = uri.toString() +} diff --git a/templates/kmp/shared/src/androidMain/kotlin/io/package/webInterface/UrlParser.android.kt.twig b/templates/kmp/shared/src/androidMain/kotlin/io/package/webInterface/UrlParser.android.kt.twig new file mode 100644 index 000000000..0940819da --- /dev/null +++ b/templates/kmp/shared/src/androidMain/kotlin/io/package/webInterface/UrlParser.android.kt.twig @@ -0,0 +1,11 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +import android.net.Uri + +actual class UrlParser { + actual fun parse(url: String): ParsedUrl = AndroidParsedUrl(Uri.parse(url)) + actual fun getQueryParameter(url: String, name: String): String? = + Uri.parse(url).getQueryParameter(name) + actual fun getHost(url: String): String = + Uri.parse(url).host ?: throw IllegalArgumentException("Invalid URL") +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/BaseClient.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/BaseClient.kt.twig new file mode 100644 index 000000000..2841ae7df --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/BaseClient.kt.twig @@ -0,0 +1,408 @@ +package {{ sdk.namespace | caseDot }} + +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception +import {{ sdk.namespace | caseDot }}.extensions.addToJsonObject +import {{ sdk.namespace | caseDot }}.extensions.getSerializer +import {{ sdk.namespace | caseDot }}.extensions.json +import {{ sdk.namespace | caseDot }}.models.InputFile +import {{ sdk.namespace | caseDot }}.models.UploadProgress +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.http.quote +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.io.Buffer +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readByteArray +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.serializer +import kotlin.reflect.KClass + +@Suppress("UNCHECKED_CAST") +abstract class BaseClient<This : BaseClient<This>>( + var endpoint: String = "{{spec.endpoint}}", + var endpointRealtime: String? = null, + private var selfSigned: Boolean = false +) : CoroutineScope { + companion object { + internal const val CHUNK_SIZE: Long = 5 * 1024 * 1024 // 5MB + } + + private val mutex = SynchronizedObject() + private val job = Job() + internal lateinit var httpClient: HttpClient + + internal lateinit var headers: MutableMap<String, String> + internal val config = mutableMapOf<String, String>() + + +{% for header in spec.global.headers %} + /** + * Set {{header.key | caseUcfirst}} + * +{% if header.description %} + * {{header.description}} + * +{% endif %} + * @param {string} {{header.key | caseLower}} + * + * @return this + */ + fun set{{header.key | caseUcfirst}}(value: String): This { + config["{{ header.key | caseCamel }}"] = value + addHeader("{{ header.name | caseLower }}", value) + return this as This + } + +{% endfor %} + /** + * Set the API endpoint URL + * + * @param endpoint The API endpoint URL + * @return [This] + */ + fun setEndpoint(endpoint: String): This { + this.endpoint = endpoint + if (this.endpointRealtime == null && endpoint.startsWith("http")) { + this.endpointRealtime = endpoint.replaceFirst("http", "ws") + } + return this as This + } + + /** + * Set the websocket endpoint URL for realtime connections + * + * @param endpoint The websocket endpoint URL + * @return [This] + */ + fun setEndpointRealtime(endpoint: String): This { + this.endpointRealtime = endpoint + return this as This + } + + fun addHeader(key: String, value: String): This { + headers[key] = value + return this as This + } + + /** + * Sends a "ping" request to {{ spec.title | caseUcfirst }} to verify connectivity. + * + * @return String + */ + suspend fun ping(): String { + val apiPath = "/ping" + val apiParams = mutableMapOf<String, Any?>() + val apiHeaders = mutableMapOf("content-type" to "application/json") + + return call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = String::class + ) + } + + /** + * Send the HTTP request + * + * @param method The HTTP method + * @param path The API path + * @param headers The request headers + * @param params The request parameters + * @param responseType The expected response type + * @param serializer Optional custom serializer + * + * @return [T] + */ + @OptIn(InternalSerializationApi::class) + @Throws(Throwable::class) + suspend fun <T : Any> call( + method: String, + path: String, + headers: Map<String, String> = mapOf(), + params: Map<String, Any?> = mapOf(), + responseType: KClass<T>, + serializer: KSerializer<T>? = null + ): T { + val filteredParams = params.filterValues { it != null } + val combinedHeaders = headers + this.headers + + return httpClient.request(endpoint + path) { + this.method = HttpMethod.parse(method) + combinedHeaders.forEach { (key, value) -> + header(key, value) + } + if (method.uppercase() == "GET") { + filteredParams.forEach { (key, value) -> + when (value) { + is List<*> -> value.forEach { item -> + parameter("${key}[]", item.toString()) + } + + else -> parameter(key, value.toString()) + } + } + } else { + contentType(ContentType.Application.Json) + val jsonObject = buildJsonObject { + filteredParams.forEach { (key, value) -> + addToJsonObject(key, value) + } + } + setBody(jsonObject) + } + }.let { response -> + handleResponse(response, responseType, serializer) + } + } + + /** + * Handle large file uploads by splitting into chunks + * + * @param path The upload endpoint path + * @param headers The request headers + * @param params The upload parameters + * @param input The InputFile to upload + * @param responseType The expected response type + * @param serializer The response serializer + * @param paramName The file parameter name + * @param idParamName Optional ID parameter name + * @param onProgress Optional progress callback + * + * @return [T] + */ + @Throws(Throwable::class) + suspend fun <T : Any> chunkedUpload( + path: String, + headers: MutableMap<String, String>, + params: MutableMap<String, Any?>, + input: InputFile, + responseType: KClass<T>, + serializer: KSerializer<T>, + paramName: String, + idParamName: String? = null, + onProgress: ((UploadProgress) -> Unit)? = null + ): T { + val filteredParams = params.filterValues { it != null } + val combinedHeaders = (headers + this.headers).toMutableMap() + + val size: Long = when (input.sourceType) { + "path", "file" -> { + SystemFileSystem.metadataOrNull(Path(input.path))?.size ?: 0L + } + + "bytes" -> { + input.data.size.toLong() + } + + else -> throw UnsupportedOperationException() + } + val data = when (input.sourceType) { + "file", "path" -> SystemFileSystem.source(Path(input.path)).buffered() + "bytes" -> input.data + else -> throw UnsupportedOperationException() + } + + if (size < CHUNK_SIZE) { + return httpClient.submitFormWithBinaryData( + url = endpoint + path, + formData = formData { + filteredParams.forEach { (key, value) -> + when (value) { + is List<*> -> value.forEach { item -> + append("${key}[]", item.toString()) + } + + else -> append(key, value.toString()) + } + } + append( + paramName, + Buffer().apply { + write(data, size) + }, + Headers.build { + append(HttpHeaders.ContentType, input.mimeType) + append( + HttpHeaders.ContentDisposition, + "filename=${input.filename.quote()}" + ) + } + ) + } + ) { + combinedHeaders.forEach { (key, value) -> + header(key, value) + } + }.let { response -> + handleResponse(response, responseType, serializer) + } + } + + var offset = 0L + var chunksUploaded = 0L + + if (idParamName?.isNotEmpty() == true) { + // Make a request to check if a file already exists + try { + val current = call( + method = "GET", + path = "$path/${params[idParamName]}", + headers = headers, + params = emptyMap(), + responseType = Map::class, + ) + chunksUploaded = current["chunksUploaded"] as Long + offset = chunksUploaded * CHUNK_SIZE + } catch (e: Exception) { + // Swallow exception + } + } + + var result: Map<*, *> = mapOf<String, Any>() + while (offset < size) { + val bytesToRead = if (size - offset < CHUNK_SIZE) { + (size - offset).toInt() + } else { + CHUNK_SIZE.toInt() + } + val chunk = data.readByteArray(bytesToRead) + + httpClient.submitFormWithBinaryData( + url = endpoint + path, + formData = formData { + filteredParams.forEach { (key, value) -> + when (value) { + is List<*> -> value.forEach { item -> + append("${key}[]", item.toString()) + } + + else -> append(key, value.toString()) + } + } + append( + paramName, + chunk, + Headers.build { + append(HttpHeaders.ContentType, input.mimeType) + append( + HttpHeaders.ContentDisposition, + "filename=${input.filename.quote()}" + ) + } + ) + } + ) { + header( + HttpHeaders.ContentRange, + "bytes $offset-${((offset + CHUNK_SIZE) - 1).coerceAtMost(size - 1)}/$size" + ) + combinedHeaders.forEach { (key, value) -> + header(key, value) + } + if (chunksUploaded != 0L) { + header("x-{{ spec.title | caseLower }}-id", result["\$id"].toString().removeSurrounding("\"")) + } + }.let { response -> + result = handleResponse(response, Map::class, getSerializer(Map::class)) + offset += bytesToRead + chunksUploaded++ + onProgress?.invoke( + UploadProgress( + id = result["\$id"].toString(), + progress = offset.coerceAtMost(size).toDouble() / size * 100, + sizeUploaded = offset.coerceAtMost(size), + chunksTotal = result["chunksTotal"].toString().toInt(), + chunksUploaded = result["chunksUploaded"].toString().toInt(), + ) + ) + } + } + return json.decodeFromString( + serializer, + json.encodeToString(getSerializer(Map::class), result) + ) + } + + @OptIn(InternalSerializationApi::class) + private suspend fun <T : Any> handleResponse( + response: HttpResponse, + responseType: KClass<T>, + serializer: KSerializer<T>? = null + ): T { + return when { + !response.status.isSuccess() -> { + val body = response.bodyAsText() + + if (response.contentType()?.match(ContentType.Application.Json) == true) { + val map = json.decodeFromString<Map<String, JsonElement>>(body) + throw {{ spec.title | caseUcfirst }}Exception( + (map["message"] as? JsonPrimitive)?.content ?: "", + (map["code"] as? JsonPrimitive)?.int ?: 0, + (map["type"] as? JsonPrimitive)?.content ?: "", + body + ) + } else { + throw {{ spec.title | caseUcfirst }}Exception(body, response.status.value) + } + } + + responseType == Boolean::class -> true as T + responseType == ByteArray::class -> response.body<ByteArray>() as T + else -> { + val body = response.bodyAsText() + if (body.isEmpty()) { + true as T + } else { + when { + body.isEmpty() -> when (responseType) { + String::class -> "" as T + Boolean::class -> true as T + else -> throw {{ spec.title | caseUcfirst }}Exception( + "Empty response body", + response.status.value + ) + } + + responseType == String::class -> body as T + else -> try { + serializer?.let { json.decodeFromString(it, body) } + ?: json.decodeFromString(responseType.serializer(), body) + } catch (e: Exception) { + throw {{ spec.title | caseUcfirst }}Exception( + "Failed to parse JSON response: ${e.message}", + response.status.value, + response = body + ) + } + } + } + } + } + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/Client.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/Client.kt.twig new file mode 100644 index 000000000..8393d809e --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/Client.kt.twig @@ -0,0 +1,8 @@ +package {{ sdk.namespace | caseDot }} + +import kotlin.coroutines.CoroutineContext + +expect class Client: BaseClient<Client> { + fun setSelfSigned(value: Boolean): Client + override val coroutineContext: CoroutineContext +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/ID.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/ID.kt.twig new file mode 100644 index 000000000..0dfb64d7e --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/ID.kt.twig @@ -0,0 +1,34 @@ +package {{ sdk.namespace | caseDot }} + +import kotlinx.datetime.Clock +import kotlin.random.Random + +/** + * Helper class to generate ID strings for resources. + */ +class ID { + companion object { + private fun toHex(number: Long, width: Int): String { + return number.toString(16).padStart(width, '0') + } + + private fun hexTimestamp(): String { + val now = Clock.System.now() + val sec = now.epochSeconds + val usec = (now.nanosecondsOfSecond / 1000) % 1000 + + return toHex(sec, 8) + toHex(usec.toLong(), 5) + } + + fun custom(id: String): String = id + + fun unique(padding: Int = 7): String { + val baseId = hexTimestamp() + val randomPadding = (1..padding) + .map { Random.nextInt(0, 16).toString(16) } + .joinToString("") + + return baseId + randomPadding + } + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/Permission.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/Permission.kt.twig new file mode 100644 index 000000000..f6c365f42 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/Permission.kt.twig @@ -0,0 +1,62 @@ +package {{ sdk.namespace | caseDot }} + +/** + * Helper class to generate permission strings for resources. + */ +class Permission { + companion object { + + /** + * Generate read permission string for the provided role. + * + * @param role The role for which to generate the permission. + * @returns The read permission string. + */ + fun read(role: String): String { + return "read(\"${role}\")" + } + + /** + * Generate write permission string for the provided role. + * + * This is an alias of update, delete, and possibly create. + * Don't use write in combination with update, delete, or create. + * + * @param role The role for which to generate the permission. + * @returns The write permission string. + */ + fun write(role: String): String { + return "write(\"${role}\")" + } + + /** + * Generate create permission string for the provided role. + * + * @param role The role for which to generate the permission. + * @returns The create permission string. + */ + fun create(role: String): String { + return "create(\"${role}\")" + } + + /** + * Generate update permission string for the provided role. + * + * @param role The role for which to generate the permission. + * @returns The update permission string. + */ + fun update(role: String): String { + return "update(\"${role}\")" + } + + /** + * Generate delete permission string for the provided role. + * + * @param role The role for which to generate the permission. + * @returns The delete permission string. + */ + fun delete(role: String): String { + return "delete(\"${role}\")" + } + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/Query.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/Query.kt.twig new file mode 100644 index 000000000..20145d415 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/Query.kt.twig @@ -0,0 +1,241 @@ +package {{ sdk.namespace | caseDot }} + +import {{ sdk.namespace | caseDot }}.extensions.json +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable + +/** + * Helper class to generate query strings. + */ +@Serializable +class Query( + val method: String, + val attribute: String? = null, + val values: List<@Contextual Any>? = null, +) { + /** + * Convert the query object to a JSON string. + * + * @returns The JSON string representation of the query object. + */ + override fun toString() = json.encodeToString(serializer(), this) + + companion object { + + /** + * Filter resources where attribute is equal to value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun equal(attribute: String, value: Any) = + Query("equal", attribute, parseValue(value)).toString() + + /** + * Filter resources where attribute is not equal to value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun notEqual(attribute: String, value: Any) = + Query("notEqual", attribute, parseValue(value)).toString() + + /** + * Filter resources where attribute is less than value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun lessThan(attribute: String, value: Any) = + Query("lessThan", attribute, parseValue(value)).toString() + + /** + * Filter resources where attribute is less than or equal to value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun lessThanEqual(attribute: String, value: Any) = + Query("lessThanEqual", attribute, parseValue(value)).toString() + + /** + * Filter resources where attribute is greater than value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun greaterThan(attribute: String, value: Any) = + Query("greaterThan", attribute, parseValue(value)).toString() + + /** + * Filter resources where attribute is greater than or equal to value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun greaterThanEqual(attribute: String, value: Any) = + Query("greaterThanEqual", attribute, parseValue(value)).toString() + + /** + * Filter resources where attribute matches the search value. + * + * @param attribute The attribute to filter on. + * @param value The search value to match against. + * @returns The query string. + */ + fun search(attribute: String, value: String) = + Query("search", attribute, listOf(value)).toString() + + /** + * Filter resources where attribute is null. + * + * @param attribute The attribute to filter on. + * @returns The query string. + */ + fun isNull(attribute: String) = Query("isNull", attribute).toString() + + /** + * Filter resources where attribute is not null. + * + * @param attribute The attribute to filter on. + * @returns The query string. + */ + fun isNotNull(attribute: String) = Query("isNotNull", attribute).toString() + + /** + * Filter resources where attribute is between start and end (inclusive). + * + * @param attribute The attribute to filter on. + * @param start The start value of the range. + * @param end The end value of the range. + * @returns The query string. + */ + fun between(attribute: String, start: Any, end: Any) = + Query("between", attribute, listOf(start, end)).toString() + + /** + * Filter resources where attribute starts with value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun startsWith(attribute: String, value: String) = + Query("startsWith", attribute, listOf(value)).toString() + + /** + * Filter resources where attribute ends with value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun endsWith(attribute: String, value: String) = + Query("endsWith", attribute, listOf(value)).toString() + + /** + * Specify which attributes should be returned by the API call. + * + * @param attributes The list of attributes to select. + * @returns The query string. + */ + fun select(attributes: List<String>) = Query("select", null, attributes).toString() + + /** + * Sort results by attribute ascending. + * + * @param attribute The attribute to sort by. + * @returns The query string. + */ + fun orderAsc(attribute: String) = Query("orderAsc", attribute).toString() + + /** + * Sort results by attribute descending. + * + * @param attribute The attribute to sort by. + * @returns The query string. + */ + fun orderDesc(attribute: String) = Query("orderDesc", attribute).toString() + + /** + * Return results before documentId. + * + * @param documentId The document ID to use as cursor. + * @returns The query string. + */ + fun cursorBefore(documentId: String) = + Query("cursorBefore", null, listOf(documentId)).toString() + + /** + * Return results after documentId. + * + * @param documentId The document ID to use as cursor. + * @returns The query string. + */ + fun cursorAfter(documentId: String) = + Query("cursorAfter", null, listOf(documentId)).toString() + + /** + * Return only limit results. + * + * @param limit The number of results to return. + * @returns The query string. + */ + fun limit(limit: Int) = Query("limit", null, listOf(limit)).toString() + + /** + * Filter resources by skipping the first offset results. + * + * @param offset The number of results to skip. + * @returns The query string. + */ + fun offset(offset: Int) = Query("offset", null, listOf(offset)).toString() + + /** + * Filter resources where attribute contains the specified value. + * + * @param attribute The attribute to filter on. + * @param value The value to compare against. + * @returns The query string. + */ + fun contains(attribute: String, value: Any) = + Query("contains", attribute, parseValue(value)).toString() + + /** + * Combine multiple queries using logical OR operator. + * + * @param queries The list of query strings to combine. + * @returns The query string. + */ + fun or(queries: List<String>) = + Query("or", null, queries.map { json.decodeFromString(serializer(), it) }).toString() + + /** + * Combine multiple queries using logical AND operator. + * + * @param queries The list of query strings to combine. + * @returns The query string. + */ + fun and(queries: List<String>) = + Query("and", null, queries.map { json.decodeFromString(serializer(), it) }).toString() + + /** + * Parse the value to a list of values. + * + * @param value The value to parse. + * @returns The list of parsed values. + */ + private fun parseValue(value: Any): List<Any> { + return when (value) { + is List<*> -> value as List<Any> + else -> listOf(value) + } + } + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/Role.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/Role.kt.twig new file mode 100644 index 000000000..0c4839dbf --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/Role.kt.twig @@ -0,0 +1,72 @@ +package {{ sdk.namespace | caseDot }} + +/** + * Helper class to generate role strings for [Permission]. + */ +class Role { + companion object { + + /** + * Grants access to anyone. + * + * This includes authenticated and unauthenticated users. + */ + fun any(): String = "any" + + /** + * Grants access to a specific user by user ID. + * + * You can optionally pass verified or unverified for + * [status] to target specific types of users. + */ + fun user(id: String, status: String = ""): String = if(status.isEmpty()) { + "user:$id" + } else { + "user:$id/$status" + } + + /** + * Grants access to any authenticated or anonymous user. + * + * You can optionally pass verified or unverified for + * [status] to target specific types of users. + */ + fun users(status: String = ""): String = if(status.isEmpty()) { + "users" + } else { + "users/$status" + } + + /** + * Grants access to any guest user without a session. + * + * Authenticated users don't have access to this role. + */ + fun guests(): String = "guests" + + /** + * Grants access to a team by team ID. + * + * You can optionally pass a role for [role] to target + * team members with the specified role. + */ + fun team(id: String, role: String = ""): String = if(role.isEmpty()) { + "team:$id" + } else { + "team:$id/$role" + } + + /** + * Grants access to a specific member of a team. + * + * When the member is removed from the team, they will + * no longer have access. + */ + fun member(id: String): String = "member:$id" + + /** + * Grants access to a user with the specified label. + */ + fun label(name: String): String = "label:$name" + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/Service.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/Service.kt.twig new file mode 100644 index 000000000..2643f5052 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/Service.kt.twig @@ -0,0 +1,8 @@ +package {{ sdk.namespace | caseDot }} + +/** + * Abstract class for services. + * + * @param client The Appwrite client. + */ +abstract class Service(val client: Client) diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/WebAuthComponent.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/WebAuthComponent.kt.twig new file mode 100644 index 000000000..97e2858c0 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/WebAuthComponent.kt.twig @@ -0,0 +1,8 @@ +package {{ sdk.namespace | caseDot }} + +expect class WebAuthComponent { + companion object { + fun onCallback(scheme: String, url: String) + fun handleIncomingCookie(url: String) + } +} \ No newline at end of file diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/coroutines/Callback.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/coroutines/Callback.kt.twig new file mode 100644 index 000000000..aa5036d1f --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/coroutines/Callback.kt.twig @@ -0,0 +1,19 @@ +package {{ sdk.namespace | caseDot }}.coroutines + +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.jvm.JvmOverloads + +interface Callback<T> { + fun onComplete(result: T?, error: Throwable?) +} + +class CoroutineCallback<T> @JvmOverloads constructor( + private val callback: Callback<T>, + override val context: CoroutineContext = Dispatchers.Default +) : Continuation<T> { + override fun resumeWith(result: Result<T>) { + callback.onComplete(result.getOrNull(), result.exceptionOrNull()) + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/enums/Enum.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/enums/Enum.kt.twig new file mode 100644 index 000000000..d9ae8d898 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/enums/Enum.kt.twig @@ -0,0 +1,16 @@ +package {{ sdk.namespace | caseDot }}.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class {{ enum.name | caseUcfirst | overrideIdentifier }}(val value: String) { +{% for value in enum.enum %} +{% set key = enum.keys is empty ? value : enum.keys[loop.index0] %} + @SerialName("{{ value }}") + {{ key | caseEnumKey }}("{{ value }}"){% if not loop.last %},{% else %};{% endif %} + +{% endfor %} + + override fun toString() = value +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/exceptions/Exception.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/exceptions/Exception.kt.twig new file mode 100644 index 000000000..80ac3af76 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/exceptions/Exception.kt.twig @@ -0,0 +1,11 @@ +package {{ sdk.namespace | caseDot }}.exceptions + +import kotlinx.serialization.Serializable + +@Serializable +class {{spec.title | caseUcfirst}}Exception( + override val message: String? = null, + val code: Int? = null, + val type: String? = null, + val response: String? = null +) : Exception(message) diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/CollectionExtensions.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/CollectionExtensions.kt.twig new file mode 100644 index 000000000..c84308701 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/CollectionExtensions.kt.twig @@ -0,0 +1,13 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +suspend fun <T> Collection<T>.forEachAsync( + callback: suspend (T) -> Unit +) = withContext(Dispatchers.IO) { + map { async { callback.invoke(it) } }.awaitAll() +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonExtensions.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonExtensions.kt.twig new file mode 100644 index 000000000..a7b26a805 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonExtensions.kt.twig @@ -0,0 +1,71 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import {{ sdk.namespace | caseDot }}.serializers.DynamicLookupSerializer +import kotlinx.serialization.Contextual +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import kotlin.reflect.KClass + +var json = Json { + encodeDefaults = true + isLenient = true + allowSpecialFloatingPointValues = true + allowStructuredMapKeys = true + prettyPrint = false + useArrayPolymorphism = false + ignoreUnknownKeys = true + coerceInputValues = true + serializersModule = SerializersModule { + contextual(Any::class, DynamicLookupSerializer) + } + explicitNulls = false +} + +@Suppress("UNCHECKED_CAST") +@OptIn(InternalSerializationApi::class) +fun <T : Any> getSerializer(kClass: KClass<T>): KSerializer<T> = when (kClass) { + Map::class -> MapSerializer(String.serializer(), JsonElement.serializer()) as KSerializer<T> + JsonObject::class -> JsonObject.serializer() as KSerializer<T> + else -> kClass.serializer() +} + +fun Any.toJson(): String = + json.encodeToString(this) + +@OptIn(InternalSerializationApi::class) +fun <T : Any> String.fromJson(clazz: KClass<T>): T = + json.decodeFromString(clazz.serializer(), this) + +inline fun <reified T> String.fromJson(): T = + json.decodeFromString<T>(this) + +fun <T : Any> Any.jsonCast(to: KClass<T>): T = + toJson().fromJson(to) + +inline fun <reified T> Any.jsonCast(): T = + toJson().fromJson<T>() + +fun <T : Any> Any.tryJsonCast(to: KClass<T>): T? = try { + toJson().fromJson(to) +} catch (ex: Exception) { + ex.printStackTrace() + null +} + +inline fun <reified T> Any.tryJsonCast(): T? = try { + toJson().fromJson<T>() +} catch (ex: Exception) { + ex.printStackTrace() + null +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonObjectBuilderExtensions.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonObjectBuilderExtensions.kt.twig new file mode 100644 index 000000000..92f133c08 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/JsonObjectBuilderExtensions.kt.twig @@ -0,0 +1,32 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.put + +fun JsonObjectBuilder.addToJsonObject(key: String, value: Any?) { + when (value) { + is String -> put(key, value) + is Number -> put(key, value) + is Boolean -> put(key, value) + is List<*> -> { + val jsonArray = JsonArray(value.map { item -> + when (item) { + is String -> JsonPrimitive(item) + is Number -> JsonPrimitive(item) + is Boolean -> JsonPrimitive(item) + is Enum<*> -> JsonPrimitive(item.name) + null -> JsonNull + else -> JsonPrimitive(item.toString()) + } + }) + put(key, jsonArray) + } + + is Enum<*> -> put(key, JsonPrimitive(value.name)) + null -> put(key, JsonNull) + else -> put(key, JsonPrimitive(value.toString())) + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/TypeExtensions.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/TypeExtensions.kt.twig new file mode 100644 index 000000000..eb90327e6 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/extensions/TypeExtensions.kt.twig @@ -0,0 +1,9 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import kotlin.reflect.KClass +import kotlin.reflect.typeOf + +inline fun <reified T : Any> classOf(): KClass<T> { + @Suppress("UNCHECKED_CAST") + return (typeOf<T>().classifier!! as KClass<T>) +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/fileOperations/FileOperations.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/fileOperations/FileOperations.kt.twig new file mode 100644 index 000000000..ae19cc45b --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/fileOperations/FileOperations.kt.twig @@ -0,0 +1,22 @@ +package {{ sdk.namespace | caseDot }}.fileOperations + +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use + +fun readFileSize(filePath: String): Long = + FileSystem.SYSTEM.metadata(filePath.toPath()).size!! + +// Helper function to read the entire file as bytes using Okio +fun readFileBytes(filePath: String): ByteArray = + FileSystem.SYSTEM.read(filePath.toPath()) { readByteArray() } + +// Helper function to read a chunk given an offset and length using Okio +fun readFileChunk(filePath: String, offset: Long, length: Int): ByteArray = + FileSystem.SYSTEM.source(filePath.toPath()).use { source -> + val bufferedSource = source.buffer() + bufferedSource.skip(offset) + bufferedSource.readByteArray(length.toLong()) + } diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/models/InputFile.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/InputFile.kt.twig new file mode 100644 index 000000000..6a7fb92db --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/InputFile.kt.twig @@ -0,0 +1,40 @@ +package {{ sdk.namespace | caseDot }}.models + +import kotlinx.io.Buffer +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.serialization.Contextual + +class InputFile private constructor() { + lateinit var path: String + lateinit var filename: String + lateinit var sourceType: String + lateinit var mimeType: String + + @Contextual + lateinit var data: Buffer + + companion object { + fun fromPath(path: String) = InputFile().apply { + this.path = SystemFileSystem.resolve(Path(path)).toString() + this.filename = path.split("/").last() + this.mimeType = guessMimeType(filename) + this.sourceType = "path" + } + + fun fromBytes( + bytes: ByteArray, + filename: String = "", + mimeType: String = "" + ) = InputFile().apply { + this.filename = filename + this.mimeType = mimeType.ifEmpty { guessMimeType(filename) } + this.data = Buffer().apply { + write(bytes) + } + this.sourceType = "bytes" + } + } +} + +expect fun guessMimeType(input: String): String \ No newline at end of file diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/models/Model.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/Model.kt.twig new file mode 100644 index 000000000..9e6b38643 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/Model.kt.twig @@ -0,0 +1,112 @@ +package {{ sdk.namespace | caseDot }}.models + +import {{ sdk.namespace | caseDot }}.extensions.jsonCast +import {{ sdk.namespace | caseDot }}.extensions.json +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** + * {{ definition.description }} + */ +@Serializable{% if definition.additionalProperties %}(with = {{ definition.name | caseUcfirst }}Serializer::class){% endif %} + +{% if definition.properties | length != 0 or definition.additionalProperties %}data {% endif %}class {{ definition | modelType(spec) | raw }}( + {%~ if definition.properties is defined %} + {%~ for property in definition.properties %} + /** + * {{ property.description }} + */ + @SerialName("{{ property.name | escapeKeyword | escapeDollarSign}}") + {%~ if property.type == 'object' %} + @Contextual + {%~ endif %} + {% if property.required -%} val + {%- else -%} var + {%- endif %} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'T', true) | raw }}, + + {%~ endfor %} + {%~ endif %} + {%~ if definition.additionalProperties %} + /** + * Additional properties + */ + @SerialName("data") + val data: T + {%~ endif %} +) + +{%~ if definition.additionalProperties %} +class {{ definition.name | caseUcfirst }}Serializer<T>(private val dataSerializer: KSerializer<T>): KSerializer<{{ definition | modelType(spec) | raw }}> { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Document") { + {%~ for property in definition.properties %} + element("{{ property.name | escapeKeyword | escapeDollarSign}}", {{ property | propertySerializerName}}.descriptor) + {%~ endfor %} + element("data", dataSerializer.descriptor) + } + + override fun deserialize(decoder: Decoder): {{ definition | modelType(spec) | raw }} { + val jsonObject = decoder.decodeSerializableValue(JsonObject.serializer()) + + // Extract system fields ($ prefixed) + {%~ for property in definition.properties %} + val {{ property.name | escapeKeyword | removeDollarSign }} = jsonObject["{{ property.name | escapeKeyword | escapeDollarSign }}"]{% if property.type == 'array' %}?.jsonArray?.map { it.jsonPrimitive.content } ?: listOf(){% else %}?.jsonPrimitive?.content ?: ""{% endif %} + + {%~ endfor %} + + // Create data object from remaining fields + val dataObject = buildJsonObject { + jsonObject.forEach { (key, value) -> + if (key.startsWith("$")) { + // Remove $ prefix for system fields + put(key.substring(1), value) + } else { + put(key, value) + } + } + } + + return {{ definition.name | caseUcfirst }}( + {%~ for property in definition.properties %} + {{ property.name | escapeKeyword | removeDollarSign }} = {{ property.name | escapeKeyword | removeDollarSign }}, + {%~ endfor %} + data = json.decodeFromJsonElement(dataSerializer, dataObject) + ) + } + + override fun serialize(encoder: Encoder, value: {{ definition | modelType(spec) | raw }}) { + val combined = buildJsonObject { + {%~ for property in definition.properties %} + put("{{ property.name | escapeKeyword | escapeDollarSign}}", {% if property.type == 'array' -%} + json.encodeToJsonElement(value.{{ property.name | escapeKeyword | removeDollarSign }})) + {%- else -%} + value.{{ property.name | escapeKeyword | removeDollarSign }}) + {%- endif %} + + {%~ endfor %} + val jsonValues = json.encodeToJsonElement(dataSerializer, value.data).jsonObject + + // Add all data fields to root + jsonValues.forEach { (key, value) -> + put(key, value) + } + } + + return encoder.encodeSerializableValue(JsonObject.serializer(), combined) + } +} +{%~ endif %} \ No newline at end of file diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/models/RealtimeModels.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/RealtimeModels.kt.twig new file mode 100644 index 000000000..086cd3ec1 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/RealtimeModels.kt.twig @@ -0,0 +1,44 @@ +package {{ sdk.namespace | caseDot }}.models + +import {{ sdk.namespace | caseDot }}.serializers.StringCollectionSerializer +import io.ktor.utils.io.core.Closeable +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +data class RealtimeSubscription( + private val close: () -> Unit +) : Closeable { + override fun close() = close.invoke() +} + +data class RealtimeCallback( + val channels: kotlin.collections.Collection<String>, + val payloadClass: KClass<*>, + val payloadSerializer: KSerializer<*>, + val callback: (RealtimeResponseEvent<*>) -> Unit +) + +@Serializable +open class RealtimeResponse( + val type: String, + @Contextual + val data: Any +) + +@Serializable +data class RealtimeResponseEvent<T>( + @Serializable(with = StringCollectionSerializer::class) + val events: kotlin.collections.Collection<String>, + @Serializable(with = StringCollectionSerializer::class) + val channels: kotlin.collections.Collection<String>, + val timestamp: String, + var payload: T +) + +enum class RealtimeCode(val value: Int) { + POLICY_VIOLATION(1008), + NORMAL_CLOSURE(1000), + UNKNOWN_ERROR(-1) +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/models/UploadProgress.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/UploadProgress.kt.twig new file mode 100644 index 000000000..3950d3bb3 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/models/UploadProgress.kt.twig @@ -0,0 +1,9 @@ +package {{ sdk.namespace | caseDot }}.models + +data class UploadProgress( + val id: String, + val progress: Double, + val sizeUploaded: Long, + val chunksTotal: Int, + val chunksUploaded: Int +) diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/serializers/DynamicLookupSerializer.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/serializers/DynamicLookupSerializer.kt.twig new file mode 100644 index 000000000..c552080be --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/serializers/DynamicLookupSerializer.kt.twig @@ -0,0 +1,88 @@ +package {{ sdk.namespace | caseDot }}.serializers + +import {{ sdk.namespace | caseDot }}.Query +import kotlinx.serialization.ContextualSerializer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.double +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull + +@Suppress("UNCHECKED_CAST") +@OptIn(ExperimentalSerializationApi::class) +object DynamicLookupSerializer : KSerializer<Any> { + override val descriptor: SerialDescriptor = + ContextualSerializer(Any::class, null, emptyArray()).descriptor + + override fun serialize(encoder: Encoder, value: Any) { + when (value) { + is String -> encoder.encodeString(value) + is Int -> encoder.encodeInt(value) + is Long -> encoder.encodeLong(value) + is Float -> encoder.encodeFloat(value) + is Double -> encoder.encodeDouble(value) + is Boolean -> encoder.encodeBoolean(value) + is Map<*, *> -> { + val mapSerializer = MapSerializer(String.serializer(), this) + encoder.encodeSerializableValue(mapSerializer, value as Map<String, Any>) + } + + is List<*> -> { + val listSerializer = ListSerializer(this) + encoder.encodeSerializableValue(listSerializer, value as List<Any>) + } + + is Query -> { + // Handle Query serialization directly + val querySerializer = Query.serializer() + encoder.encodeSerializableValue(querySerializer, value) + } + + else -> encoder.encodeString(value.toString()) + } + } + + override fun deserialize(decoder: Decoder): Any { + val element = decoder.decodeSerializableValue(JsonElement.serializer()) + return deserializeJsonElement(element) ?: Unit + } + + private fun deserializeJsonElement(element: JsonElement): Any? { + return when (element) { + is JsonPrimitive -> when { + element.isString -> element.jsonPrimitive.content + element.intOrNull != null -> element.int + element.longOrNull != null -> element.long + element.doubleOrNull != null -> element.double + element.booleanOrNull != null -> element.boolean + else -> element.content + } + + is JsonObject -> element.toDeserializedMap() + is JsonArray -> element.map { deserializeJsonElement(it) } + JsonNull -> null + } + } + + private fun JsonObject.toDeserializedMap(): Map<String, Any?> { + return mapValues { (_, value) -> deserializeJsonElement(value) } + } + +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/serializers/StringCollectionSeriailizer.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/serializers/StringCollectionSeriailizer.kt.twig new file mode 100644 index 000000000..d4e99594b --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/serializers/StringCollectionSeriailizer.kt.twig @@ -0,0 +1,26 @@ +package {{ sdk.namespace | caseDot }}.serializers + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +class StringCollectionSerializer : KSerializer<Collection<String>> { + override val descriptor = ListSerializer(String.serializer()).descriptor + + override fun deserialize(decoder: Decoder): Collection<String> { + val array = decoder.decodeSerializableValue(JsonArray.serializer()) + return array.map { it.jsonPrimitive.content } + } + + override fun serialize(encoder: Encoder, value: Collection<String>) { + encoder.encodeSerializableValue( + JsonArray.serializer(), + JsonArray(value.map { JsonPrimitive(it) }) + ) + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/services/Realtime.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/services/Realtime.kt.twig new file mode 100644 index 000000000..df8dde9bb --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/services/Realtime.kt.twig @@ -0,0 +1,229 @@ +package {{ sdk.namespace | caseDot }}.services + +import {{ sdk.namespace | caseDot }}.Client +import {{ sdk.namespace | caseDot }}.Service +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception +import {{ sdk.namespace | caseDot }}.extensions.forEachAsync +import {{ sdk.namespace | caseDot }}.extensions.fromJson +import {{ sdk.namespace | caseDot }}.extensions.getSerializer +import {{ sdk.namespace | caseDot }}.extensions.json +import {{ sdk.namespace | caseDot }}.extensions.jsonCast +import {{ sdk.namespace | caseDot }}.extensions.toJson +import {{ sdk.namespace | caseDot }}.models.RealtimeCallback +import {{ sdk.namespace | caseDot }}.models.RealtimeCode +import {{ sdk.namespace | caseDot }}.models.RealtimeResponse +import {{ sdk.namespace | caseDot }}.models.RealtimeResponseEvent +import {{ sdk.namespace | caseDot }}.models.RealtimeSubscription +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.url +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.KSerializer +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KClass + +class Realtime(client: Client) : Service(client), CoroutineScope { + private val job = Job() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + + private companion object { + private const val TYPE_ERROR = "error" + private const val TYPE_EVENT = "event" + private const val DEBOUNCE_MILLIS = 1L + + private var webSocketSession: DefaultClientWebSocketSession? = null + private var activeChannels = mutableSetOf<String>() + private var activeSubscriptions = mutableMapOf<Int, RealtimeCallback>() + + private var subCallDepth = 0 + private var reconnectAttempts = 0 + private var subscriptionsCounter = 0 + private var reconnect = true + } + + private fun createSocket() { + launch(Dispatchers.IO) { + try { + + if (activeChannels.isEmpty()) { + reconnect = false + closeSocket() + return@launch + } + + val queryParams = buildString { + append("project=${client.config["project"]}") + activeChannels.forEach { + append("&channels[]=$it") + } + } + + if (webSocketSession != null) { + reconnect = false + closeSocket() + } + + webSocketSession = client.httpClient.webSocketSession { + url("${client.endpointRealtime}/realtime?$queryParams") + } + + reconnectAttempts = 0 // Reset attempts on successful connection + webSocketSession?.let { session -> + for (frame in session.incoming) { + when (frame) { + is Frame.Text -> handleMessage(frame.readText()) + else -> {} + } + } + } + + } catch (e: Exception) { + handleFailure(e) + } finally { + handleClosing(RealtimeCode.NORMAL_CLOSURE.value, "Connection closed") + } + } + } + + @Throws(Throwable::class) + suspend fun closeSocket() { + webSocketSession?.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "")) + webSocketSession = null + } + + private fun getTimeout() = when { + reconnectAttempts < 5 -> 1000L + reconnectAttempts < 15 -> 5000L + reconnectAttempts < 100 -> 10000L + else -> 60000L + } + + fun subscribe( + channels: List<String>, + callback: (RealtimeResponseEvent<Any>) -> Unit, + ) = subscribe( + channels = channels, + Any::class, + callback = callback + ) + + @Suppress("UNCHECKED_CAST") + fun <T : Any> subscribe( + channels: List<String>, + payloadType: KClass<T>, + payloadSerializer: KSerializer<T>? = null, + callback: (RealtimeResponseEvent<T>) -> Unit, + ): RealtimeSubscription { + val counter = subscriptionsCounter++ + + activeChannels.addAll(channels) + + val actualSerializer = payloadSerializer ?: getSerializer(payloadType) + + activeSubscriptions[counter] = RealtimeCallback( + channels.toList(), + payloadType, + actualSerializer, + callback as (RealtimeResponseEvent<*>) -> Unit + ) + + launch { + subCallDepth++ + delay(DEBOUNCE_MILLIS) + if (subCallDepth == 1) { + createSocket() + } + subCallDepth-- + } + + return RealtimeSubscription { + activeSubscriptions.remove(counter) + cleanUp(channels) + } + } + + private fun cleanUp(channels: List<String>) { + activeChannels.removeAll { channel -> + if (!channels.contains(channel)) { + return@removeAll false + } + activeSubscriptions.values.none { callback -> + callback.channels.contains(channel) + } + } + } + + @Throws(Throwable::class) + suspend fun handleMessage(text: String) { + val message = text.fromJson(RealtimeResponse::class) + when (message.type) { + TYPE_ERROR -> handleResponseError(message) + TYPE_EVENT -> handleResponseEvent(message) + } + } + + private fun handleResponseError(message: RealtimeResponse) { + throw message.data.jsonCast<{{ spec.title | caseUcfirst }}Exception>() + } + + @Throws(Throwable::class) + suspend fun handleResponseEvent(message: RealtimeResponse) { + val mapSerializer = getSerializer(Map::class) + val event = json.decodeFromString( + RealtimeResponseEvent.serializer(mapSerializer), + message.data.toJson() + ) + if (event.channels.isEmpty()) { + return + } + if (!event.channels.any { activeChannels.contains(it) }) { + return + } + activeSubscriptions.values.forEachAsync { subscription -> + if (event.channels.any { subscription.channels.contains(it) }) { + val payloadSerializer = + subscription.payloadSerializer + + val eventWithPayloadClass = json.decodeFromString( + RealtimeResponseEvent.serializer(payloadSerializer), + message.data.toJson() + ) + subscription.callback(eventWithPayloadClass) + } + } + } + + @Throws(Throwable::class) + suspend fun handleClosing(code: Int, reason: String) { + if (!reconnect || code == RealtimeCode.POLICY_VIOLATION.value) { + reconnect = true + return + } + + val timeout = getTimeout() + println("Realtime disconnected. Re-connecting in ${timeout / 1000} seconds.") + + delay(timeout) + reconnectAttempts++ + createSocket() + } + + private fun handleFailure(error: Throwable) { + error.printStackTrace() + launch { + delay(getTimeout()) + reconnectAttempts++ + createSocket() + } + } +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/services/Service.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/services/Service.kt.twig new file mode 100644 index 000000000..cd2f3aade --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/services/Service.kt.twig @@ -0,0 +1,191 @@ +package {{ sdk.namespace | caseDot }}.services + +import {{ sdk.namespace | caseDot }}.Client +import {{ sdk.namespace | caseDot }}.Service +{%~ if spec.definitions is not empty %} +import {{ sdk.namespace | caseDot }}.models.* +{%~ endif %} +{%~ if spec.enums is not empty %} +import {{ sdk.namespace | caseDot }}.enums.* +{%~ endif %} +import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception +import {{ sdk.namespace | caseDot }}.extensions.* +import {{ sdk.namespace | caseDot }}.serializers.* +import {{ sdk.namespace | caseDot }}.webInterface.UrlParser +import kotlinx.serialization.KSerializer +import kotlin.jvm.JvmOverloads +import kotlin.reflect.KClass +{%~ if service.features.location %} +import kotlinx.serialization.serializer +import io.ktor.client.plugins.cookies.cookies +import io.ktor.client.request.cookie +import io.ktor.client.request.get +import io.ktor.http.Cookie +{%~ endif %} + +/** + * {{ service.description | raw | replace({"\n": "", "\r": ""}) }} + **/ +class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { +{%~ for method in service.methods %} + {% if method.type != "webAuth" %} + /** + * {{ method.title }} + * + * {{ method.description | raw | replace({"\n": "", "\r": ""}) }} + * + {%~ if method.parameters.all | reduce((carry, param) => carry or not param.required) %} + @JvmOverloads + {%~ endif %} + @Throws(Throwable::class) + {%~ for parameter in method.parameters.all %} + * @param {{ parameter.name | caseCamel }} {{ parameter.description | raw }} + {%~ endfor %} + */ + @Throws(Throwable::class) + {%~ if method.parameters.all | reduce((carry, param) => carry or not param.required) %} + @JvmOverloads + {%~ endif %} + {%~ if method.responseModel | hasGenericType(spec) %} + suspend inline fun {{ '<reified T : Any>' | raw }} {{ method.name | caseCamel }}( + {%~ else %} + suspend fun {{ method.name | caseCamel }}( + {%~ endif %} + {%~ for parameter in method.parameters.all %} + {{ parameter.name | caseCamel }}: {{ parameter | typeName }}{%~ if not parameter.required or parameter.nullable %}? = null{%~ endif %}, + {%~ endfor %} + {%~ if method.responseModel | hasGenericType(spec) %} + nestedType: KClass<T>?, + genericSerializer: KSerializer<T>? = null, + {%~ endif %} + {%~ if 'multipart/form-data' in method.consumes %} + onProgress: ((UploadProgress) -> Unit)? = null + {%~ endif %} + ): {{ method | returnType(spec, sdk.namespace | caseDot) | raw }} { + val apiPath = "{{ method.path }}" + {%~ for parameter in method.parameters.path %} + .replace("{{ '{' ~ parameter.name | caseCamel ~ '}' }}", {{ parameter.name | caseCamel }}{%~ if parameter.enumValues is not empty %}.value{%~ endif %}) + {%~ endfor %} + + {%~ if method.responseModel | hasGenericType(spec) %} + val actualSerializer = genericSerializer ?: getSerializer(T::class) + {%~ endif %} + + val apiParams = mutableMapOf<String, Any?>( + {%~ for parameter in method.parameters.query | merge(method.parameters.body) %} + {%~ if (parameter.name == "data" or parameter.name == "prefs") and method.responseModel | hasGenericType(spec) %} + "{{ parameter.name }}" to json.encodeToString(actualSerializer, {{ parameter.name }} as T), + {%~ else %} + "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, + {%~ endif %} + {%~ endfor %} + {%~ if method.type == 'location'%} + {%~ if method.auth | length > 0 %} + {%~ for node in method.auth %} + {%~ for key,header in node | keys %} + "{{ header | caseLower }}" to client.config["{{ header | caseLower }}"], + {%~ endfor %} + {%~ endfor %} + {%~ endif %} + {%~ endif %} + ) + {%~ if method.type == 'location' %} + return client.call( + "{{ method.method | caseUpper }}", + apiPath, + params = apiParams, + responseType = {{ method | returnType(spec, sdk.namespace | caseDot) | raw }}::class + ) + {%~ else %} + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + {%~ for key, header in method.headers %} + "{{ key }}" to "{{ header }}", + {%~ endfor %} + ) + + {%~ if 'multipart/form-data' in method.consumes %} + val idParamName: String? = {%~ if method.parameters.all | filter(p => p.isUploadID) | length > 0 %}{%~ for parameter in method.parameters.all | filter(parameter => parameter.isUploadID) %}"{{ parameter.name }}"{%~ endfor %}{%~ else %}null{%~ endif %} + + {%~ for parameter in method.parameters.all %} + {%~ if parameter.type == 'file' %} + val paramName = "{{ parameter.name }}" + {%~ endif %} + {%~ endfor %} + + return client.chunkedUpload( + apiPath, + apiHeaders, + apiParams, + {%~ for parameter in method.parameters.all %} + {%~ if parameter.type == 'file' %} + {{ parameter.name }}, + {%~ endif %} + {%~ endfor %} + responseType = {{ method | returnType(spec, sdk.namespace | caseDot) | raw }}::class, + {%~ if method.responseModel %} + {{ method | returnType(spec, sdk.namespace | caseDot, 'T', false) | raw }}.serializer(), + {%~ endif %} + paramName, + idParamName, + onProgress + ) + {%~ else %} + return client.call( + "{{ method.method | caseUpper }}", + apiPath, + apiHeaders, + apiParams, + {%~ if method.responseModel | hasGenericType(spec) %} + responseType = classOf(), + {%~ else %} + responseType = {{ method | returnType(spec, sdk.namespace | caseDot) | raw }}::class, + {%~ endif %} + {%~ if method | returnType(spec, '') | raw == 'Any' %} + serializer = DynamicLookupSerializer + {%~ elseif method.responseModel | hasGenericType(spec) %} + serializer = {{ method | returnType(spec, sdk.namespace | caseDot, 'T', false) | raw }}.serializer(actualSerializer) + {%~ else %} + serializer = {{ method | returnType(spec, sdk.namespace | caseDot, 'T', false) | raw }}.serializer() + {%~ endif %} + ) + {%~ endif %} + {%~ endif %} + } + + {%~ if method.responseModel | hasGenericType(spec) %} + /** + * {{ method.title }} + * + * {{ method.description | raw | replace({"\n": "", "\r": ""}) }} + * + {%~ for parameter in method.parameters.all %} + * @param {{ parameter.name | caseCamel }} {{ parameter.description | raw }} + {%~ endfor %} + */ + @Throws(Throwable::class) + {%~ if method.parameters.all | reduce((carry, param) => carry or not param.required) %} + @JvmOverloads + {%~ endif %} + suspend fun {{ method.name | caseCamel }}( + {%~ for parameter in method.parameters.all %} + {{ parameter.name | caseCamel }}: {{ parameter | typeName }}{%~ if not parameter.required or parameter.nullable %}? = null{%~ endif %}, + {%~ endfor %} + {%~ if 'multipart/form-data' in method.consumes %} + onProgress: ((UploadProgress) -> Unit)? = null + {%~ endif %} + ): {{ method | returnType(spec, sdk.namespace | caseDot, 'Map<String, Any>') | raw }} = {{ method.name | caseCamel }}( + {%~ for parameter in method.parameters.all %} + {{ parameter.name | caseCamel }}, + {%~ endfor %} + {%~ if method.responseModel | hasGenericType(spec) %} + nestedType = classOf(), + {%~ endif %} + {%~ if 'multipart/form-data' in method.consumes %} + onProgress = onProgress + {%~ endif %} + ) + {%~ endif %} + {% endif %} +{%~ endfor %} +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/webInterface/ParsedUrl.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/webInterface/ParsedUrl.kt.twig new file mode 100644 index 000000000..27ff19163 --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/webInterface/ParsedUrl.kt.twig @@ -0,0 +1,5 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +interface ParsedUrl { + override fun toString(): String +} diff --git a/templates/kmp/shared/src/commonMain/kotlin/io/package/webInterface/UrlParser.kt.twig b/templates/kmp/shared/src/commonMain/kotlin/io/package/webInterface/UrlParser.kt.twig new file mode 100644 index 000000000..075be207e --- /dev/null +++ b/templates/kmp/shared/src/commonMain/kotlin/io/package/webInterface/UrlParser.kt.twig @@ -0,0 +1,7 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +expect class UrlParser() { + fun parse(url: String): ParsedUrl + fun getQueryParameter(url: String, name: String): String? + fun getHost(url: String): String +} diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/Client.ios.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/Client.ios.kt.twig new file mode 100644 index 000000000..d488c7e26 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/Client.ios.kt.twig @@ -0,0 +1,46 @@ +package {{ sdk.namespace | caseDot }} + +import {{ sdk.namespace | caseDot }}.cookies.IosCookieStorage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import platform.Foundation.NSBundle +import platform.UIKit.UIDevice + +actual class Client( + endpoint: String = "{{spec.endpoint}}", + endpointRealtime: String? = null, + selfSigned: Boolean = false, +) : BaseClient<Client>(endpoint, endpointRealtime) { + actual override val coroutineContext = Job() + Dispatchers.Default + + private val appVersion: String by lazy { + NSBundle.mainBundle.infoDictionary?.get("CFBundleShortVersionString") as? String ?: "" + } + + private val bundleId: String by lazy { + NSBundle.mainBundle.bundleIdentifier ?: "" + } + + val iosCookieStorage = IosCookieStorage() + + init { + httpClient = createHttpClient(selfSigned, iosCookieStorage) + headers = mutableMapOf( + "content-type" to "application/json", + "x-sdk-name" to "{{ sdk.name }}", + "x-sdk-platform" to "{{ sdk.platform }}", + "x-sdk-language" to "{{ language.name | caseLower }}", + "x-sdk-version" to "{{ sdk.version }}"{% if spec.global.defaultHeaders | length > 0 %},{% endif %} + + {%~ for key,header in spec.global.defaultHeaders %} + "{{key | caseLower }}" to "{{header}}"{% if not loop.last %},{% endif %} + + {%~ endfor %} + ) + } + + actual fun setSelfSigned(value: Boolean): Client { + httpClient = createHttpClient(value, iosCookieStorage) + return this + } +} diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/HttpClientConfig.ios.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/HttpClientConfig.ios.kt.twig new file mode 100644 index 000000000..02bc8c14c --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/HttpClientConfig.ios.kt.twig @@ -0,0 +1,60 @@ +package {{ sdk.namespace | caseDot }} + +import {{ sdk.namespace | caseDot }}.cookies.IosCookieStorage +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.pingInterval +import io.ktor.serialization.kotlinx.json.json +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.convert +import platform.Foundation.NSURLCredential +import platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling +import platform.Foundation.NSURLSessionAuthChallengeUseCredential +import platform.Foundation.credentialForTrust +import platform.Foundation.serverTrust +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalForeignApi::class) +fun createHttpClient(selfSigned: Boolean, iosCookieStorage: IosCookieStorage) = HttpClient(Darwin) { + install(HttpCookies) { + storage = iosCookieStorage + } + install(WebSockets) { + pingInterval = 30.seconds + } + + install(HttpTimeout) { + requestTimeoutMillis = 60000 + connectTimeoutMillis = 30000 + socketTimeoutMillis = 30000 + } + + install(ContentNegotiation) { + json(io.appwrite.extensions.json) + } + + if (selfSigned) { + engine { + handleChallenge { _, _, challenge, completionHandler -> + val disposition = NSURLSessionAuthChallengeUseCredential + val credential = + NSURLCredential.credentialForTrust(challenge.protectionSpace.serverTrust!!) + println("Challenge response: $disposition") + completionHandler(disposition.convert(), credential) + } + } + } else { + engine { + // Use default SSL configuration for secure connections + handleChallenge { session, task, challenge, completionHandler -> + val disposition = NSURLSessionAuthChallengePerformDefaultHandling + println("Challenge response: $disposition") + completionHandler(disposition.convert(), null) + } + } + } +} diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/WebAuthComponent.ios.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/WebAuthComponent.ios.kt.twig new file mode 100644 index 000000000..8d86544d1 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/WebAuthComponent.ios.kt.twig @@ -0,0 +1,118 @@ +package {{ sdk.namespace | caseDot }} + +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.Foundation.NSArray +import platform.Foundation.NSURL +import platform.Foundation.NSURLComponents +import platform.Foundation.NSURLQueryItem +import platform.Foundation.NSUserDefaults +import platform.Foundation.arrayWithObject +import platform.UIKit.UIApplication +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +actual class WebAuthComponent { + + actual companion object { + private data class PendingAuth( + val continuation: kotlinx.coroutines.CancellableContinuation<String>, + val onComplete: ((Result<String>) -> Unit)? + ) + + private val pendingAuth = mutableMapOf<String, PendingAuth>() + + /** + * Initiates authentication by opening the URL. + * The suspend function will resume once onCallback is invoked. + */ + internal suspend fun authenticate( + url: String, + callbackUrlScheme: String, + onComplete: ((Result<String>) -> Unit)? + ): String = suspendCancellableCoroutine { cont -> + pendingAuth[callbackUrlScheme] = PendingAuth(cont, onComplete) + + val nsUrl = NSURL.URLWithString(url) + if (nsUrl == null) { + pendingAuth.remove(callbackUrlScheme) + cont.resumeWithException(IllegalArgumentException("Invalid URL: $url")) + return@suspendCancellableCoroutine + } + + UIApplication.sharedApplication.openURL(nsUrl, options = emptyMap<Any?, Any>()) { success -> + if (!success) { + pendingAuth.remove(callbackUrlScheme)?.let { pending -> + pending.onComplete?.invoke(Result.failure(IllegalStateException("Failed to open URL"))) + } + cont.resumeWithException(IllegalStateException("Failed to open URL")) + } + } + + cont.invokeOnCancellation { + pendingAuth.remove(callbackUrlScheme) + } + } + + /** + * Called when the app receives the callback URL. + * This method resumes the suspended coroutine associated with the given scheme. + */ + actual fun onCallback(scheme: String, url: String) { + pendingAuth.remove(scheme)?.let { pending -> + pending.onComplete?.invoke(Result.success(url)) + pending.continuation.resume(url) + } + } + + /** + * Process an incoming URL callback by parsing its query parameters to extract cookie details. + * Saves the cookie and then calls onCallback. + */ + actual fun handleIncomingCookie(url: String) { + // Construct URL components from the callback URL. + val components = NSURLComponents(url) + val queryItems = components.queryItems ?: return + + // Map query items into key-value pairs. + val cookieParts = queryItems.mapNotNull { item -> + when (item) { + is NSURLQueryItem -> Pair(item.name, item.value ?: "") + else -> null + } + }.toMap() + + // Ensure required parameters are present. + val rawDomain = cookieParts["domain"] ?: return + val key = cookieParts["key"] ?: return + val secret = cookieParts["secret"] ?: return + + // Remove the first character from the domain (as done in Swift). + val domain = if (rawDomain.isNotEmpty()) rawDomain.substring(1) else rawDomain + + // Optional cookie attributes. + val path = cookieParts["path"] + val expires = cookieParts["expires"] + val maxAge = cookieParts["maxAge"] + val sameSite = cookieParts["sameSite"] + val httpOnly = cookieParts.containsKey("httpOnly") + val secure = cookieParts.containsKey("secure") + + // Build the cookie string. + var cookie = "$key=$secret" + path?.let { cookie += "; path=$it" } + expires?.let { cookie += "; expires=$it" } + maxAge?.let { cookie += "; max-age=$it" } + sameSite?.let { cookie += "; sameSite=$it" } + if (httpOnly) { cookie += "; httpOnly" } + if (secure) { cookie += "; secure" } + + // Save the cookie in UserDefaults. + NSUserDefaults.standardUserDefaults.setObject(NSArray.arrayWithObject(cookie), forKey = domain) + + // Retrieve the callback scheme from the URL components and resume the pending auth. + val schemeFromComponents = components.scheme ?: return + val callbackURLString = components.URL?.absoluteString ?: return + onCallback(schemeFromComponents, callbackURLString) + } + } +} \ No newline at end of file diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/cookies/IosCookieStorage.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/cookies/IosCookieStorage.kt.twig new file mode 100644 index 000000000..e0e066f53 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/cookies/IosCookieStorage.kt.twig @@ -0,0 +1,260 @@ +package {{ sdk.namespace | caseDot }}.cookies + +import io.ktor.client.plugins.cookies.CookiesStorage +import io.ktor.http.Cookie +import io.ktor.http.Url +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSHTTPCookie +import platform.Foundation.NSHTTPCookieAcceptPolicy +import platform.Foundation.NSHTTPCookieDomain +import platform.Foundation.NSHTTPCookieExpires +import platform.Foundation.NSHTTPCookieName +import platform.Foundation.NSHTTPCookiePath +import platform.Foundation.NSHTTPCookieStorage +import platform.Foundation.NSHTTPCookieValue +import platform.Foundation.NSRecursiveLock +import platform.Foundation.NSTimeZone +import platform.Foundation.NSURL +import platform.Foundation.NSUserDefaults +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.timeIntervalSince1970 +import platform.Foundation.timeZoneWithAbbreviation +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +@Serializable +private data class StoredCookie( + val name: String, + val value: String, + val domain: String, + val path: String, + val expires: Double?, + val isSecure: Boolean, + val isHttpOnly: Boolean, + val maxAge: Int? = null +) + +class IosCookieStorage : CookiesStorage { + private val cookieStorage = NSHTTPCookieStorage.sharedHTTPCookieStorage.apply { + cookieAcceptPolicy = NSHTTPCookieAcceptPolicy.NSHTTPCookieAcceptPolicyAlways + } + private val lock = NSRecursiveLock() + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + init { + initialize() + } + + private fun initialize() { + lock.lock() + try { + cleanupExpiredCookies() + restoreCookies() + } catch (e: Exception) { + println("Failed to initialize cookies: ${e.message}") + } finally { + lock.unlock() + } + } + + override suspend fun get(requestUrl: Url): List<Cookie> { + lock.lock() + return try { + val nsUrl = NSURL(string = requestUrl.toString()) + cookieStorage.cookiesForURL(nsUrl) + ?.map { (it as NSHTTPCookie).toKtorCookie() } + ?: emptyList() + } catch (e: Exception) { + println("Failed to get cookies: ${e.message}") + emptyList() + } finally { + lock.unlock() + } + } + + override suspend fun addCookie(requestUrl: Url, cookie: Cookie) { + lock.lock() + try { + val nsUrl = NSURL(string = requestUrl.toString()) + // Use cookie's domain if provided, otherwise use the host without leading dot + val domain = cookie.domain ?: requestUrl.host + + val setCookieHeader = buildCookieHeader(cookie, domain) + val storedCookie = createStoredCookie(cookie, domain) + + persistCookie(storedCookie) + + NSHTTPCookie.cookiesWithResponseHeaderFields( + mapOf("Set-Cookie" to setCookieHeader), + nsUrl + ).forEach { + cookieStorage.setCookie(it as NSHTTPCookie) + } + } catch (e: Exception) { + println("Failed to add cookie: ${e.message}") + } finally { + lock.unlock() + } + } + + + override fun close() { + lock.lock() + try { + cookieStorage.cookies?.forEach { + cookieStorage.deleteCookie(it as NSHTTPCookie) + } + clearStoredCookies() + } finally { + lock.unlock() + } + } + + private fun buildCookieHeader(cookie: Cookie, domain: String): String = buildString { + append("${cookie.name}=${cookie.value}") + val cookieDomain = if (!domain.startsWith(".") && !domain.equals("localhost", true)) { + ".$domain" + } else domain + append("; Domain=${cookieDomain}") // Only one Domain attribute + append("; Path=${cookie.path ?: "/"}") + cookie.expires?.let { gmtDate -> + append("; Expires=${formatDate(gmtDate.timestamp)}") + } + cookie.maxAge?.let { + append("; Max-Age=$it") + } + if (cookie.secure) append("; Secure") + if (cookie.httpOnly) append("; HttpOnly") + } + + + private fun createStoredCookie(cookie: Cookie, domain: String) = StoredCookie( + name = cookie.name, + value = cookie.value, + domain = domain, + path = cookie.path ?: "/", + expires = cookie.expires?.timestamp?.toDouble(), + isSecure = cookie.secure, + isHttpOnly = cookie.httpOnly, + maxAge = cookie.maxAge + ) + + private fun persistCookie(storedCookie: StoredCookie) { + val key = getCookieKey(storedCookie.domain, storedCookie.name, storedCookie.path) + val cookieJson = json.encodeToString(StoredCookie.serializer(), storedCookie) + NSUserDefaults.standardUserDefaults.setObject(cookieJson, key) + } + + private fun restoreCookies() { + val userDefaults = NSUserDefaults.standardUserDefaults + userDefaults.dictionaryRepresentation().forEach { (key, value) -> + if (key !is String || !key.startsWith("cookie:")) return@forEach + + try { + val cookieJson = value as? String ?: return@forEach + val storedCookie = json.decodeFromString(StoredCookie.serializer(), cookieJson) + + if (isExpired(storedCookie)) { + userDefaults.removeObjectForKey(key) + return@forEach + } + + // Validate domain and path before recreating + if (storedCookie.domain.isNotBlank() && storedCookie.path.isNotBlank()) { + recreateCookie(storedCookie) + } else { + userDefaults.removeObjectForKey(key) + } + } catch (e: Exception) { + println("Failed to restore cookie: ${e.message}") + userDefaults.removeObjectForKey(key) + } + } + } + + + private fun recreateCookie(storedCookie: StoredCookie) { + val properties = mutableMapOf<Any?, Any?>( + NSHTTPCookieName to storedCookie.name, + NSHTTPCookieValue to storedCookie.value, + NSHTTPCookiePath to storedCookie.path, + NSHTTPCookieDomain to storedCookie.domain + ) + + storedCookie.expires?.let { + properties[NSHTTPCookieExpires] = NSDate.dateWithTimeIntervalSince1970(it / 1000) + } + + NSHTTPCookie.cookieWithProperties(properties)?.let { + cookieStorage.setCookie(it) + } + } + + private fun cleanupExpiredCookies() { + cookieStorage.cookies?.forEach { cookie -> + cookie as NSHTTPCookie + if (isExpired(cookie)) { + cookieStorage.deleteCookie(cookie) + removeFromUserDefaults(cookie) + } + } + } + + private fun isExpired(cookie: NSHTTPCookie): Boolean { + val expiresDate = cookie.expiresDate ?: return false + return expiresDate.timeIntervalSince1970 < NSDate().timeIntervalSince1970 + } + + private fun isExpired(cookie: StoredCookie): Boolean { + val expires = cookie.expires ?: return false + return (expires / 1000) < NSDate().timeIntervalSince1970 + } + + private fun removeFromUserDefaults(cookie: NSHTTPCookie) { + val key = getCookieKey(cookie.domain, cookie.name, cookie.path) + NSUserDefaults.standardUserDefaults.removeObjectForKey(key) + } + + private fun clearStoredCookies() { + val userDefaults = NSUserDefaults.standardUserDefaults + userDefaults.dictionaryRepresentation().keys + .filter { (it as? String)?.startsWith("cookie:") == true } + .forEach { key -> + userDefaults.removeObjectForKey(key as String) + } + } + + + private fun getCookieKey(domain: String, name: String, path: String): String { + return "cookie:$domain:$name:$path" + } + + private fun formatDate(timestamp: Long): String { + val date = NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble() / 1000) + val formatter = NSDateFormatter().apply { + dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + timeZone = NSTimeZone.timeZoneWithAbbreviation("GMT")!! + } + return formatter.stringFromDate(date) + } + + + private fun NSHTTPCookie.toKtorCookie(): Cookie = Cookie( + name = name, + value = value, + domain = domain, + path = path, + secure = isSecure(), + httpOnly = isHTTPOnly(), + maxAge = expiresDate?.let { + (it.timeIntervalSince1970 - NSDate().timeIntervalSince1970).toInt() + } + ) +} diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig new file mode 100644 index 000000000..740a37992 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig @@ -0,0 +1,76 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import {{ sdk.namespace | caseDot }}.WebAuthComponent +import {{ sdk.namespace | caseDot }}.exceptions.AppwriteException +import {{ sdk.namespace | caseDot }}.webInterface.UrlParser +import io.ktor.http.Cookie +import io.ktor.http.Url +import kotlinx.coroutines.runBlocking + +{% for service in spec | webAuthServices %} +{% for method in service.methods %} +suspend fun {{ sdk.namespace | caseDot }}.services.{{ service.className | caseUcfirst }}.{{ method.methodName }}( + {%~ for parameter in method.parameters.all %} + {{ parameter.name | caseCamel }}: {{ parameter | typeName }}{%~ if not parameter.required or parameter.nullable %}? = null{%~ endif %}, + {%~ endfor %} +) { + val apiPath = "{{ method.path }}" + {%~ for parameter in method.parameters.path %} + .replace("{{ '{' ~ parameter.name | caseCamel ~ '}' }}", {{ parameter.name | caseCamel }}{%~ if parameter.enumValues is not empty %}.value{%~ endif %}) + {%~ endfor %} + + val apiParams = mutableMapOf<String, Any?>( + {%~ for parameter in method.parameters.query | merge(method.parameters.body) %} + "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, + {%~ endfor %} + {%~ if method.auth | length > 0 %} + {%~ for node in method.auth %} + {%~ for key,header in node | keys %} + "{{ header | caseLower }}" to client.config["{{ header | caseLower }}"], + {%~ endfor %} + {%~ endfor %} + {%~ endif %} + ) + + val apiQuery = mutableListOf<String>() + apiParams.forEach { + when (it.value) { + null -> return@forEach + is List<*> -> apiQuery.add("${it.key}[]=${it.value.toString()}") + else -> apiQuery.add("${it.key}=${it.value.toString()}") + } + } + + val urlParser = UrlParser() + val fullUrl = "${client.endpoint}${apiPath}?${apiQuery.joinToString("&")}" + val apiUrl = urlParser.parse(fullUrl) + val callbackUrlScheme = "{{ spec.title | caseLower }}-callback-${client.config["project"]}" + + WebAuthComponent.authenticate(apiUrl.toString(), callbackUrlScheme) { + if (it.isFailure) { + throw it.exceptionOrNull()!! + } + + val resultUrl = it.getOrNull()!! + val key = urlParser.getQueryParameter(resultUrl, "key") + val secret = urlParser.getQueryParameter(resultUrl, "secret") + if (key == null || secret == null) { + throw {{ spec.title | caseUcfirst }}Exception("Authentication cookie missing!") + } + + runBlocking { + client.iosCookieStorage.addCookie( + Url(fullUrl), + Cookie( + name = key, + value = secret, + domain = urlParser.getHost(client.endpoint), + httpOnly = true, + ) + ) + } + } +} + +{% endfor %} +{% endfor %} diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/models/InputFile.ios.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/models/InputFile.ios.kt.twig new file mode 100644 index 000000000..905e31ff1 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/models/InputFile.ios.kt.twig @@ -0,0 +1,44 @@ +package {{ sdk.namespace | caseDot }}.models + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreFoundation.CFStringRef +import platform.CoreServices.UTTypeCopyPreferredTagWithClass +import platform.CoreServices.UTTypeCreatePreferredIdentifierForTag +import platform.CoreServices.kUTTagClassFilenameExtension +import platform.CoreServices.kUTTagClassMIMEType +import platform.Foundation.CFBridgingRelease +import platform.Foundation.NSURL +import platform.Foundation.NSString +import platform.CoreFoundation.CFStringCreateWithCString +import platform.CoreFoundation.kCFAllocatorDefault +import platform.CoreFoundation.kCFStringEncodingUTF8 + +@OptIn(ExperimentalForeignApi::class) +actual fun guessMimeType(input: String): String { + val url = NSURL.fileURLWithPath(input) + val ext = url.pathExtension + if (ext == null || ext.isEmpty()) return "application/octet-stream" + + // Create a CFStringRef from the Kotlin String + val cfExt: CFStringRef? = CFStringCreateWithCString( + kCFAllocatorDefault, + ext, + kCFStringEncodingUTF8 + ) + + // Create the UTI from the file extension. + val utiRef = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, + cfExt, + null + )?.let { CFBridgingRelease(it) } as? CFStringRef + + // Get the MIME type from the UTI. + val mimeCF = UTTypeCopyPreferredTagWithClass( + utiRef, + kUTTagClassMIMEType + )?.let { CFBridgingRelease(it) } as? CFStringRef + + // Convert the CFString (bridged to an NSString) to a Kotlin String. + return (mimeCF as? NSString)?.toString() ?: "application/octet-stream" +} \ No newline at end of file diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/webInterface/IosParsedUrl.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/webInterface/IosParsedUrl.kt.twig new file mode 100644 index 000000000..ad2fdf430 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/webInterface/IosParsedUrl.kt.twig @@ -0,0 +1,7 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +import platform.Foundation.NSURL + +class IosParsedUrl(private val url: NSURL) : ParsedUrl { + override fun toString(): String = url.absoluteString ?: "" +} diff --git a/templates/kmp/shared/src/iosMain/kotlin/io/package/webInterface/UrlParser.ios.kt.twig b/templates/kmp/shared/src/iosMain/kotlin/io/package/webInterface/UrlParser.ios.kt.twig new file mode 100644 index 000000000..28860f795 --- /dev/null +++ b/templates/kmp/shared/src/iosMain/kotlin/io/package/webInterface/UrlParser.ios.kt.twig @@ -0,0 +1,27 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +import platform.Foundation.NSURL +import platform.Foundation.NSURLComponents +import platform.Foundation.NSURLQueryItem + +actual class UrlParser { + actual fun parse(url: String): ParsedUrl { + val nsUrl = NSURL(string = url) + return IosParsedUrl(nsUrl) + } + + actual fun getQueryParameter(url: String, name: String): String? { + val components = NSURLComponents(string = url) + return components.queryItems?.firstOrNull { queryItem -> + (queryItem as NSURLQueryItem).name == name + }?.let { (it as NSURLQueryItem).value } + } + + actual fun getHost(url: String): String { + val components = NSURLComponents(string = url) + return components.host ?: throw IllegalArgumentException("Invalid URL") + } +} + + + diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/AllCertsTrustManager.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/AllCertsTrustManager.kt.twig new file mode 100644 index 000000000..5143661bd --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/AllCertsTrustManager.kt.twig @@ -0,0 +1,21 @@ +package {{ sdk.namespace | caseDot }} + +import javax.net.ssl.X509TrustManager + +class AllCertsTrustManager : X509TrustManager { + @Suppress("TrustAllX509TrustManager") + override fun checkClientTrusted( + chain: Array<out java.security.cert.X509Certificate>?, + authType: String? + ) { + } + + @Suppress("TrustAllX509TrustManager") + override fun checkServerTrusted( + chain: Array<out java.security.cert.X509Certificate>?, + authType: String? + ) { + } + + override fun getAcceptedIssuers(): Array<out java.security.cert.X509Certificate> = arrayOf() +} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/Client.jvm.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/Client.jvm.kt.twig new file mode 100644 index 000000000..a34cd438f --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/Client.jvm.kt.twig @@ -0,0 +1,47 @@ +package {{ sdk.namespace | caseDot }} + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import java.util.Properties + +actual class Client( + endpoint: String = "{{spec.endpoint}}", + endpointRealtime: String? = null, + selfSigned: Boolean = false, +) : BaseClient<Client>(endpoint, endpointRealtime) { + actual override val coroutineContext = Job() + Dispatchers.Default + + private val appVersion by lazy { + try { + val properties = Properties() + properties.load(this.javaClass.getResourceAsStream("/version.properties")) + return@lazy properties.getProperty("version", "unknown") + } catch (e: Exception) { + e.printStackTrace() + return@lazy "unknown" + } + } + + init { + httpClient = createHttpClient(selfSigned) + headers = mutableMapOf( + "origin" to "appwrite-jvm://app", + "content-type" to "application/json", + "origin" to "{{ spec.title | caseLower }}-jvm://app", + "user-agent" to "JVM/$appVersion, ${System.getProperty("java.version")}", + "x-sdk-name" to "{{ sdk.name }}", + "x-sdk-platform" to "{{ sdk.platform }}", + "x-sdk-language" to "{{ language.name | caseLower }}", + "x-sdk-version" to "{{ sdk.version }}"{% if spec.global.defaultHeaders | length > 0 %},{% endif %} + + {% for key,header in spec.global.defaultHeaders %} + "{{ key | caseLower }}" to "{{ header }}"{% if not loop.last %},{% endif %} + {% endfor %} + ) + } + + actual fun setSelfSigned(value: Boolean): Client { + httpClient = createHttpClient(value) + return this + } +} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/HttpClient.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/HttpClient.kt.twig new file mode 100644 index 000000000..5f445dd5d --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/HttpClient.kt.twig @@ -0,0 +1,64 @@ +package {{ sdk.namespace | caseDot }} + +import io.ktor.client.HttpClient +import io.ktor.client.engine.java.Java +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.pingInterval +import io.ktor.serialization.kotlinx.json.json +import java.security.KeyStore +import java.security.SecureRandom +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import kotlin.time.Duration.Companion.seconds + +fun createHttpClient(selfSigned: Boolean) = HttpClient(Java) { + install(HttpCookies) { + storage = AcceptAllCookiesStorage() + } + + install(WebSockets) { + pingInterval = 30.seconds + } + + install(HttpTimeout) { + requestTimeoutMillis = 60000 + connectTimeoutMillis = 30000 + socketTimeoutMillis = 30000 + } + + install(ContentNegotiation) { + json({{ sdk.namespace | caseDot }}.extensions.json) + } + + if (selfSigned) { + engine { + config { + sslContext(SSLContext.getInstance("TLS").apply { + init(null, arrayOf(AllCertsTrustManager()), SecureRandom()) + }) + } + } + } else { + engine { + config { + val trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm() + ).apply { + init(null as KeyStore?) + } + + sslContext(SSLContext.getInstance("TLS").apply { + init( + null, + trustManagerFactory.trustManagers, + SecureRandom() + ) + }) + } + } + } +} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/WebAuthComponent.jvm.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/WebAuthComponent.jvm.kt.twig new file mode 100644 index 000000000..bca273eec --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/WebAuthComponent.jvm.kt.twig @@ -0,0 +1,87 @@ +package {{ sdk.namespace | caseDot }} + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.awt.Desktop +import java.net.URI + +actual class WebAuthComponent { + actual companion object { + private var suspended = false + private val callbacks = mutableMapOf<String, (((Result<String>) -> Unit)?)>() + + suspend fun authenticate( + url: String, + callbackUrlScheme: String, + onComplete: ((Result<String>) -> Unit)? + ) { + callbacks[callbackUrlScheme] = onComplete + + try { + val desktop = if (Desktop.isDesktopSupported()) Desktop.getDesktop() else null + + if (desktop != null && desktop.isSupported(Desktop.Action.BROWSE)) { + withContext(Dispatchers.IO) { + desktop.browse(URI(url)) + } + } else { + // Fallback for systems where Desktop is not supported + val runtime = Runtime.getRuntime() + when { + System.getProperty("os.name").lowercase().contains("mac") -> { + withContext(Dispatchers.IO) { + runtime.exec(arrayOf("open", url)) + } + } + + System.getProperty("os.name").lowercase().contains("win") -> { + withContext(Dispatchers.IO) { + runtime.exec( + arrayOf( + "rundll32", + "url.dll,FileProtocolHandler", + url + ) + ) + } + } + + else -> { + withContext(Dispatchers.IO) { + runtime.exec(arrayOf("xdg-open", url)) + } + } + } + } + + suspended = true + while (suspended) { + delay(200) + } + } catch (e: Exception) { + onComplete?.invoke(Result.failure(e)) + cleanUp() + } + } + + private fun cleanUp() { + callbacks.forEach { (_, danglingResultCallback) -> + danglingResultCallback?.invoke( + Result.failure(IllegalStateException("User cancelled login")) + ) + } + callbacks.clear() + suspended = false + } + + actual fun onCallback(scheme: String, url: String) { + callbacks.remove(scheme)?.invoke( + Result.success(url) + ) + suspended = false + } + + actual fun handleIncomingCookie(url: String) {} + } +} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig new file mode 100644 index 000000000..648b85b92 --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/extensions/OAuth2Extensions.kt.twig @@ -0,0 +1,83 @@ +package {{ sdk.namespace | caseDot }}.extensions + +import {{ sdk.namespace | caseDot }}.WebAuthComponent +import {{ sdk.namespace | caseDot }}.exceptions.AppwriteException +import {{ sdk.namespace | caseDot }}.services.Account +import {{ sdk.namespace | caseDot }}.webInterface.UrlParser +import io.ktor.client.plugins.cookies.cookies +import io.ktor.client.request.cookie +import io.ktor.client.request.get +import io.ktor.http.Cookie + +{% for service in spec | webAuthServices %} +{% for method in service.methods %} +suspend fun {{ sdk.namespace | caseDot }}.services.{{ service.className | caseUcfirst }}.{{ method.methodName }}( + {%~ for parameter in method.parameters.all %} + {{ parameter.name | caseCamel }}: {{ parameter | typeName }}{%~ if not parameter.required or parameter.nullable %}? = null{%~ endif %}, + {%~ endfor %} +) { + val apiPath = "{{ method.path }}" + {%~ for parameter in method.parameters.path %} + .replace("{{ '{' ~ parameter.name | caseCamel ~ '}' }}", {{ parameter.name | caseCamel }}{%~ if parameter.enumValues is not empty %}.value{%~ endif %}) + {%~ endfor %} + + val apiParams = mutableMapOf<String, Any?>( + {%~ for parameter in method.parameters.query | merge(method.parameters.body) %} + "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, + {%~ endfor %} + {%~ if method.auth | length > 0 %} + {%~ for node in method.auth %} + {%~ for key,header in node | keys %} + "{{ header | caseLower }}" to client.config["{{ header | caseLower }}"], + {%~ endfor %} + {%~ endfor %} + {%~ endif %} + ) + + val apiQuery = mutableListOf<String>() + apiParams.forEach { + when (it.value) { + null -> return@forEach + is List<*> -> apiQuery.add("${it.key}[]=${it.value.toString()}") + else -> apiQuery.add("${it.key}=${it.value.toString()}") + } + } + + val urlParser = UrlParser() + val fullUrl = "${client.endpoint}${apiPath}?${apiQuery.joinToString("&")}" + val apiUrl = urlParser.parse(fullUrl) + val callbackUrlScheme = "appwrite-callback-${client.config["project"]}" + + WebAuthComponent.authenticate(apiUrl.toString(), callbackUrlScheme) { + if (it.isFailure) { + throw it.exceptionOrNull()!! + } + + val resultUrl = it.getOrNull()!! + val key = urlParser.getQueryParameter(resultUrl, "key") + val secret = urlParser.getQueryParameter(resultUrl, "secret") + if (key == null || secret == null) { + throw AppwriteException("Authentication cookie missing!") + } + + kotlinx.coroutines.runBlocking { + val existingCookies = client.httpClient.cookies(client.endpoint) + if (existingCookies.any { cookie: Cookie -> cookie.name == key }) { + return@runBlocking + } + + client.httpClient.get(client.endpoint) { + cookie( + name = key, + value = secret, + domain = urlParser.getHost(client.endpoint), + path = "/", + httpOnly = true + ) + } + } + } +} + +{% endfor %} +{% endfor %} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/models/InputFile.jvm.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/models/InputFile.jvm.kt.twig new file mode 100644 index 000000000..3903bea0a --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/models/InputFile.jvm.kt.twig @@ -0,0 +1,13 @@ +package {{ sdk.namespace | caseDot }}.models + +import java.io.File +import java.net.URLConnection +import java.nio.file.Files +import java.nio.file.Paths + +actual fun guessMimeType(input: String): String { + val file = File(input) + return Files.probeContentType(file.toPath()) ?: + URLConnection.guessContentTypeFromName(file.name) ?: + "application/octet-stream" +} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/JvmParsedUrl.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/JvmParsedUrl.kt.twig new file mode 100644 index 000000000..6b5526133 --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/JvmParsedUrl.kt.twig @@ -0,0 +1,7 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +import java.net.URI + +class JvmParsedUrl(private val uri: URI) : ParsedUrl { + override fun toString(): String = uri.toString() +} diff --git a/templates/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/UrlParser.jvm.kt.twig b/templates/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/UrlParser.jvm.kt.twig new file mode 100644 index 000000000..b70da64e8 --- /dev/null +++ b/templates/kmp/shared/src/jvmMain/kotlin/io/package/webInterface/UrlParser.jvm.kt.twig @@ -0,0 +1,19 @@ +package {{ sdk.namespace | caseDot }}.webInterface + +import java.net.URI + +actual class UrlParser { + actual fun parse(url: String): ParsedUrl = JvmParsedUrl(URI(url)) + + actual fun getQueryParameter(url: String, name: String): String? { + val uri = URI(url) + val query = uri.query ?: return null + return query.split('&') + .map { it.split('=', limit = 2) } + .find { it[0] == name } + ?.getOrNull(1) + } + + actual fun getHost(url: String): String = + URI(url).host ?: throw IllegalArgumentException("Invalid URL") +} diff --git a/templates/php/tests/IDTest.php.twig b/templates/php/tests/IDTest.php.twig index a48a6b5d8..491437745 100644 --- a/templates/php/tests/IDTest.php.twig +++ b/templates/php/tests/IDTest.php.twig @@ -1,15 +1,15 @@ -<?php - -namespace Appwrite; - -use PHPUnit\Framework\TestCase; - -final class IDTest extends TestCase { - public function testUnique(): void { - $this->assertSame('unique()', ID::unique()); - } - - public function testCustom(): void { - $this->assertSame('custom', ID::custom('custom')); - } -} +<?php + +namespace Appwrite; + +use PHPUnit\Framework\TestCase; + +final class IDTest extends TestCase { + public function testUnique(): void { + $this->assertSame('unique()', ID::unique()); + } + + public function testCustom(): void { + $this->assertSame('custom', ID::custom('custom')); + } +} diff --git a/templates/php/tests/PermissionTest.php.twig b/templates/php/tests/PermissionTest.php.twig index cf4078407..b17958df9 100644 --- a/templates/php/tests/PermissionTest.php.twig +++ b/templates/php/tests/PermissionTest.php.twig @@ -1,27 +1,27 @@ -<?php - -namespace Appwrite; - -use PHPUnit\Framework\TestCase; - -final class PermissionTest extends TestCase { - public function testRead(): void { - $this->assertSame('read("any")', Permission::read(Role::any())); - } - - public function testWrite(): void { - $this->assertSame('write("any")', Permission::write(Role::any())); - } - - public function testCreate(): void { - $this->assertSame('create("any")', Permission::create(Role::any())); - } - - public function testUpdate(): void { - $this->assertSame('update("any")', Permission::update(Role::any())); - } - - public function testDelete(): void { - $this->assertSame('delete("any")', Permission::delete(Role::any())); - } -} +<?php + +namespace Appwrite; + +use PHPUnit\Framework\TestCase; + +final class PermissionTest extends TestCase { + public function testRead(): void { + $this->assertSame('read("any")', Permission::read(Role::any())); + } + + public function testWrite(): void { + $this->assertSame('write("any")', Permission::write(Role::any())); + } + + public function testCreate(): void { + $this->assertSame('create("any")', Permission::create(Role::any())); + } + + public function testUpdate(): void { + $this->assertSame('update("any")', Permission::update(Role::any())); + } + + public function testDelete(): void { + $this->assertSame('delete("any")', Permission::delete(Role::any())); + } +} diff --git a/templates/php/tests/QueryTest.php.twig b/templates/php/tests/QueryTest.php.twig index 900c7e429..bd54d136d 100644 --- a/templates/php/tests/QueryTest.php.twig +++ b/templates/php/tests/QueryTest.php.twig @@ -1,149 +1,149 @@ -<?php - -namespace Appwrite; - -use PHPUnit\Framework\TestCase; - -final class BasicFilterQueryTest { - public $description; - public $value; - public $expectedValues; - - public function __construct(string $description, mixed $value, string $expectedValues) { - $this->description = $description; - $this->value = $value; - $this->expectedValues = $expectedValues; - } -} - -final class QueryTest extends TestCase { - /** - * @var BasicFilterQueryTest[] $tests - */ - private $tests; - - function __construct(string $name) - { - parent::__construct($name); - $this->tests = array( - new BasicFilterQueryTest('with a string', 's', '["s"]'), - new BasicFilterQueryTest('with a integer', 1, '[1]'), - new BasicFilterQueryTest('with a double', 1.2, '[1.2]'), - new BasicFilterQueryTest('with a whole number double', 1.0, '[1]'), - new BasicFilterQueryTest('with a bool', false, '[false]'), - new BasicFilterQueryTest('with a list', ['a', 'b', 'c'], '["a","b","c"]'), - ); - } - - public function testBasicFilterEqual(): void { - foreach($this->tests as $test) { - $this->assertSame( - "equal(\"attr\", $test->expectedValues)", - Query::equal('attr', $test->value), - $test->description, - ); - } - } - - public function testBasicFilterNotEqual(): void { - foreach($this->tests as $test) { - $this->assertSame( - "notEqual(\"attr\", $test->expectedValues)", - Query::notEqual('attr', $test->value), - $test->description, - ); - } - } - - public function testBasicFilterLessThan(): void { - foreach($this->tests as $test) { - $this->assertSame( - "lessThan(\"attr\", $test->expectedValues)", - Query::lessThan('attr', $test->value), - $test->description, - ); - } - } - - public function testBasicFilterLessThanEqual(): void { - foreach($this->tests as $test) { - $this->assertSame( - "lessThanEqual(\"attr\", $test->expectedValues)", - Query::lessThanEqual('attr', $test->value), - $test->description, - ); - } - } - - public function testBasicFilterGreaterThan(): void { - foreach($this->tests as $test) { - $this->assertSame( - "greaterThan(\"attr\", $test->expectedValues)", - Query::greaterThan('attr', $test->value), - $test->description, - ); - } - } - - public function testBasicFilterGreaterThanEqual(): void { - foreach($this->tests as $test) { - $this->assertSame( - "greaterThanEqual(\"attr\", $test->expectedValues)", - Query::greaterThanEqual('attr', $test->value), - $test->description, - ); - } - } - - public function testSearch(): void { - $this->assertSame('search("attr", ["keyword1 keyword2"])', Query::search('attr', 'keyword1 keyword2')); - } - - public function testIsNull(): void { - $this->assertSame('isNull("attr")', Query::isNull('attr')); - } - - public function testIsNotNull(): void { - $this->assertSame('isNotNull("attr")', Query::isNotNull('attr')); - } - - public function testBetweenWithIntegers(): void { - $this->assertSame('between("attr", 1, 2)', Query::between('attr', 1, 2)); - } - - public function testBetweenWithDoubles(): void { - $this->assertSame('between("attr", 1, 2)', Query::between('attr', 1.0, 2.0)); - } - - public function testBetweenWithStrings(): void { - $this->assertSame('between("attr", "a", "z")', Query::between('attr', 'a', 'z')); - } - - public function testSelect(): void { - $this->assertSame('select(["attr1","attr2"])', Query::select(['attr1', 'attr2'])); - } - - public function testOrderAsc(): void { - $this->assertSame('orderAsc("attr")', Query::orderAsc('attr')); - } - - public function testOrderDesc(): void { - $this->assertSame('orderDesc("attr")', Query::orderDesc('attr')); - } - - public function testCursorBefore(): void { - $this->assertSame('cursorBefore("attr")', Query::cursorBefore('attr')); - } - - public function testCursorAfter(): void { - $this->assertSame('cursorAfter("attr")', Query::cursorAfter('attr')); - } - - public function testLimit(): void { - $this->assertSame('limit(1)', Query::limit(1)); - } - - public function testOffset(): void { - $this->assertSame('offset(1)', Query::offset(1)); - } -} +<?php + +namespace Appwrite; + +use PHPUnit\Framework\TestCase; + +final class BasicFilterQueryTest { + public $description; + public $value; + public $expectedValues; + + public function __construct(string $description, mixed $value, string $expectedValues) { + $this->description = $description; + $this->value = $value; + $this->expectedValues = $expectedValues; + } +} + +final class QueryTest extends TestCase { + /** + * @var BasicFilterQueryTest[] $tests + */ + private $tests; + + function __construct(string $name) + { + parent::__construct($name); + $this->tests = array( + new BasicFilterQueryTest('with a string', 's', '["s"]'), + new BasicFilterQueryTest('with a integer', 1, '[1]'), + new BasicFilterQueryTest('with a double', 1.2, '[1.2]'), + new BasicFilterQueryTest('with a whole number double', 1.0, '[1]'), + new BasicFilterQueryTest('with a bool', false, '[false]'), + new BasicFilterQueryTest('with a list', ['a', 'b', 'c'], '["a","b","c"]'), + ); + } + + public function testBasicFilterEqual(): void { + foreach($this->tests as $test) { + $this->assertSame( + "equal(\"attr\", $test->expectedValues)", + Query::equal('attr', $test->value), + $test->description, + ); + } + } + + public function testBasicFilterNotEqual(): void { + foreach($this->tests as $test) { + $this->assertSame( + "notEqual(\"attr\", $test->expectedValues)", + Query::notEqual('attr', $test->value), + $test->description, + ); + } + } + + public function testBasicFilterLessThan(): void { + foreach($this->tests as $test) { + $this->assertSame( + "lessThan(\"attr\", $test->expectedValues)", + Query::lessThan('attr', $test->value), + $test->description, + ); + } + } + + public function testBasicFilterLessThanEqual(): void { + foreach($this->tests as $test) { + $this->assertSame( + "lessThanEqual(\"attr\", $test->expectedValues)", + Query::lessThanEqual('attr', $test->value), + $test->description, + ); + } + } + + public function testBasicFilterGreaterThan(): void { + foreach($this->tests as $test) { + $this->assertSame( + "greaterThan(\"attr\", $test->expectedValues)", + Query::greaterThan('attr', $test->value), + $test->description, + ); + } + } + + public function testBasicFilterGreaterThanEqual(): void { + foreach($this->tests as $test) { + $this->assertSame( + "greaterThanEqual(\"attr\", $test->expectedValues)", + Query::greaterThanEqual('attr', $test->value), + $test->description, + ); + } + } + + public function testSearch(): void { + $this->assertSame('search("attr", ["keyword1 keyword2"])', Query::search('attr', 'keyword1 keyword2')); + } + + public function testIsNull(): void { + $this->assertSame('isNull("attr")', Query::isNull('attr')); + } + + public function testIsNotNull(): void { + $this->assertSame('isNotNull("attr")', Query::isNotNull('attr')); + } + + public function testBetweenWithIntegers(): void { + $this->assertSame('between("attr", 1, 2)', Query::between('attr', 1, 2)); + } + + public function testBetweenWithDoubles(): void { + $this->assertSame('between("attr", 1, 2)', Query::between('attr', 1.0, 2.0)); + } + + public function testBetweenWithStrings(): void { + $this->assertSame('between("attr", "a", "z")', Query::between('attr', 'a', 'z')); + } + + public function testSelect(): void { + $this->assertSame('select(["attr1","attr2"])', Query::select(['attr1', 'attr2'])); + } + + public function testOrderAsc(): void { + $this->assertSame('orderAsc("attr")', Query::orderAsc('attr')); + } + + public function testOrderDesc(): void { + $this->assertSame('orderDesc("attr")', Query::orderDesc('attr')); + } + + public function testCursorBefore(): void { + $this->assertSame('cursorBefore("attr")', Query::cursorBefore('attr')); + } + + public function testCursorAfter(): void { + $this->assertSame('cursorAfter("attr")', Query::cursorAfter('attr')); + } + + public function testLimit(): void { + $this->assertSame('limit(1)', Query::limit(1)); + } + + public function testOffset(): void { + $this->assertSame('offset(1)', Query::offset(1)); + } +} diff --git a/templates/php/tests/RoleTest.php.twig b/templates/php/tests/RoleTest.php.twig index 9a800d54d..f6e0be1cb 100644 --- a/templates/php/tests/RoleTest.php.twig +++ b/templates/php/tests/RoleTest.php.twig @@ -1,47 +1,47 @@ -<?php - -namespace Appwrite; - -use PHPUnit\Framework\TestCase; - -final class RoleTest extends TestCase { - public function testAny(): void { - $this->assertSame('any', Role::any()); - } - - public function testUserWithoutStatus(): void { - $this->assertSame('user:custom', Role::user('custom')); - } - - public function testUserWithStatus(): void { - $this->assertSame('user:custom/verified', Role::user('custom', 'verified')); - } - - public function testUsersWithoutStatus(): void { - $this->assertSame('users', Role::users()); - } - - public function testUsersWithStatus(): void { - $this->assertSame('users/verified', Role::users('verified')); - } - - public function testGuests(): void { - $this->assertSame('guests', Role::guests()); - } - - public function testTeamWithoutRole(): void { - $this->assertSame('team:custom', Role::team('custom')); - } - - public function testTeamWithRole(): void { - $this->assertSame('team:custom/owner', Role::team('custom', 'owner')); - } - - public function testMember(): void { - $this->assertSame('member:custom', Role::member('custom')); - } - - public function testLabel(): void { - $this->assertSame('label:admin', Role::label('admin')); - } -} +<?php + +namespace Appwrite; + +use PHPUnit\Framework\TestCase; + +final class RoleTest extends TestCase { + public function testAny(): void { + $this->assertSame('any', Role::any()); + } + + public function testUserWithoutStatus(): void { + $this->assertSame('user:custom', Role::user('custom')); + } + + public function testUserWithStatus(): void { + $this->assertSame('user:custom/verified', Role::user('custom', 'verified')); + } + + public function testUsersWithoutStatus(): void { + $this->assertSame('users', Role::users()); + } + + public function testUsersWithStatus(): void { + $this->assertSame('users/verified', Role::users('verified')); + } + + public function testGuests(): void { + $this->assertSame('guests', Role::guests()); + } + + public function testTeamWithoutRole(): void { + $this->assertSame('team:custom', Role::team('custom')); + } + + public function testTeamWithRole(): void { + $this->assertSame('team:custom/owner', Role::team('custom', 'owner')); + } + + public function testMember(): void { + $this->assertSame('member:custom', Role::member('custom')); + } + + public function testLabel(): void { + $this->assertSame('label:admin', Role::label('admin')); + } +} diff --git a/templates/php/tests/Services/ServiceTest.php.twig b/templates/php/tests/Services/ServiceTest.php.twig index 6e31e9bf3..67c23393d 100644 --- a/templates/php/tests/Services/ServiceTest.php.twig +++ b/templates/php/tests/Services/ServiceTest.php.twig @@ -1,44 +1,44 @@ -<?php - -namespace Appwrite\Services; - -use Appwrite\Client; -use Appwrite\InputFile; -use Mockery; -use PHPUnit\Framework\TestCase; - -final class {{service.name | caseUcfirst}}Test extends TestCase { - private $client; - private ${{service.name | caseCamel}}; - - protected function setUp(): void { - $this->client = Mockery::mock(Client::class); - $this->{{ service.name | caseCamel }} = new {{ service.name | caseUcfirst }}($this->client); - } - -{% for method in service.methods %} - public function testMethod{{method.name | caseUcfirst}}(): void { - {%~ if method.responseModel and method.responseModel != 'any' ~%} - $data = array( - {%- for definition in spec.definitions ~%}{%~ if definition.name == method.responseModel -%}{%~ for property in definition.properties | filter((param) => param.required) ~%} - "{{property.name | escapeDollarSign}}" => {% if property.type == 'object' %}array(){% elseif property.type == 'array' %}array(){% elseif property.type == 'string' %}"{{property.example | escapeDollarSign}}"{% elseif property.type == 'boolean' %}true{% else %}{{property.example}}{% endif %},{%~ endfor ~%}{% set break = true %}{%- else -%}{% set continue = true %}{%- endif -%}{%~ endfor -%} - ); - {%~ elseif (method.responseModel and method.responseModel == 'any') or method.type == 'webAuth' ~%} - $data = array(); - {%~ else ~%} - $data = ''; - {%~ endif ~%} - - $this->client - ->allows()->call(Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any()) - ->andReturn($data); - - $response = $this->{{service.name | caseCamel}}->{{method.name | caseCamel}}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} - {% if parameter.type == 'object' %}array(){% elseif parameter.type == 'array' %}array(){% elseif parameter.type == 'file' %}InputFile::withData('', "image/png"){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}"{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}"{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%}{% if not loop.last %},{% endif %}{%~ endfor ~%} - ); - - $this->assertSame($data, $response); - } - -{% endfor %} -} +<?php + +namespace Appwrite\Services; + +use Appwrite\Client; +use Appwrite\InputFile; +use Mockery; +use PHPUnit\Framework\TestCase; + +final class {{service.name | caseUcfirst}}Test extends TestCase { + private $client; + private ${{service.name | caseCamel}}; + + protected function setUp(): void { + $this->client = Mockery::mock(Client::class); + $this->{{ service.name | caseCamel }} = new {{ service.name | caseUcfirst }}($this->client); + } + +{% for method in service.methods %} + public function testMethod{{method.name | caseUcfirst}}(): void { + {%~ if method.responseModel and method.responseModel != 'any' ~%} + $data = array( + {%- for definition in spec.definitions ~%}{%~ if definition.name == method.responseModel -%}{%~ for property in definition.properties | filter((param) => param.required) ~%} + "{{property.name | escapeDollarSign}}" => {% if property.type == 'object' %}array(){% elseif property.type == 'array' %}array(){% elseif property.type == 'string' %}"{{property.example | escapeDollarSign}}"{% elseif property.type == 'boolean' %}true{% else %}{{property.example}}{% endif %},{%~ endfor ~%}{% set break = true %}{%- else -%}{% set continue = true %}{%- endif -%}{%~ endfor -%} + ); + {%~ elseif (method.responseModel and method.responseModel == 'any') or method.type == 'webAuth' ~%} + $data = array(); + {%~ else ~%} + $data = ''; + {%~ endif ~%} + + $this->client + ->allows()->call(Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any()) + ->andReturn($data); + + $response = $this->{{service.name | caseCamel}}->{{method.name | caseCamel}}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} + {% if parameter.type == 'object' %}array(){% elseif parameter.type == 'array' %}array(){% elseif parameter.type == 'file' %}InputFile::withData('', "image/png"){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}"{% if parameter.example is not empty %}{{parameter.example | escapeDollarSign}}{% endif %}"{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{parameter.example}}{%~ endif ~%}{% if not loop.last %},{% endif %}{%~ endfor ~%} + ); + + $this->assertSame($data, $response); + } + +{% endfor %} +} diff --git a/tests/KMPAndroid14Java17Test.php b/tests/KMPAndroid14Java17Test.php new file mode 100644 index 000000000..b1345e03d --- /dev/null +++ b/tests/KMPAndroid14Java17Test.php @@ -0,0 +1,36 @@ +<?php + +namespace Tests; + +class KMPAndroid14Java17Test extends Base +{ + protected string $sdkName = 'kmp'; + protected string $sdkPlatform = 'client'; + protected string $sdkLanguage = 'kmp'; + protected string $version = '0.0.1'; + + protected string $language = 'kmp'; + protected string $class = 'Appwrite\SDK\Language\KMP'; + protected array $build = [ + 'mkdir -p tests/sdks/kmp/shared/src/androidUnitTest/kotlin', + 'cp tests/languages/kmp/Tests.kt tests/sdks/kmp/shared/src/androidUnitTest/kotlin/Tests.kt', + 'chmod +x tests/sdks/kmp/gradlew', + ]; + protected string $command = + 'docker run --rm --platform linux/amd64 --network="mockapi" -v $(pwd):/app -w /app/tests/sdks/kmp alvrme/alpine-android:android-34-jdk17 sh -c "./gradlew :shared:testDebugUnitTest --stacktrace -q && cat shared/result.txt"'; + + protected array $expectedOutput = [ + ...Base::PING_RESPONSE, + ...Base::FOO_RESPONSES, + ...Base::BAR_RESPONSES, + ...Base::GENERAL_RESPONSES, + ...Base::UPLOAD_RESPONSES, + ...Base::ENUM_RESPONSES, + ...Base::EXCEPTION_RESPONSES, + ...Base::REALTIME_RESPONSES, + // ...Base::COOKIE_RESPONSES, + ...Base::QUERY_HELPER_RESPONSES, + ...Base::PERMISSION_HELPER_RESPONSES, + ...Base::ID_HELPER_RESPONSES + ]; +} diff --git a/tests/languages/kmp/Tests.kt b/tests/languages/kmp/Tests.kt new file mode 100644 index 000000000..175b6670d --- /dev/null +++ b/tests/languages/kmp/Tests.kt @@ -0,0 +1,317 @@ +package io.appwrite + +import androidx.test.core.app.ApplicationProvider +import io.appwrite.exceptions.AppwriteException +import io.appwrite.enums.MockType +import io.appwrite.extensions.fromJson +import io.appwrite.models.InputFile +import io.appwrite.models.Mock +import io.appwrite.services.Bar +import io.appwrite.services.Foo +import io.appwrite.services.General +import io.appwrite.services.Realtime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.Serializable +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Paths + + +@Serializable +data class TestPayload(val response: String) + + +@RunWith(RobolectricTestRunner::class) +class ServiceTest { + + private val filename: String = "result.txt" + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + Files.deleteIfExists(Paths.get(filename)) + writeToFile("Test Started") + } + + @After + @ExperimentalCoroutinesApi + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + @Throws(IOException::class) + fun test() { + val client = Client(ApplicationProvider.getApplicationContext()) + .setProject("123456") + .addHeader("Origin", "http://localhost") + .setSelfSigned(true) + + runBlocking { + val ping = client.ping() + val pingResponse = parse(ping) + writeToFile(pingResponse) + } + + // reset configs + client.setProject("console") + .setEndpointRealtime("wss://cloud.appwrite.io/v1") + + val foo = Foo(client) + val bar = Bar(client) + val general = General(client) + val realtime = Realtime(client) + var realtimeResponse = "Realtime failed!" + + realtime.subscribe(listOf("tests"), payloadType = TestPayload::class) { + realtimeResponse = it.payload.response + } + + runBlocking { + var mock: Mock + // Foo Tests + try { + mock = foo.get("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed foo get: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = foo.post("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed foo post: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = foo.put("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed foo put: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = foo.patch("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed foo patch: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = foo.delete("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed foo delete: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + + // Bar Tests + try { + mock = bar.get("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed bar get: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = bar.post("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed bar post: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = bar.put("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed bar put: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = bar.patch("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed bar patch: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + try { + mock = bar.delete("string", 123, listOf("string in array")) + } catch (e: Exception) { + writeToFile("Failed bar delete: ${e.message}") + return@runBlocking + } + writeToFile(mock.result) + + // General Tests + val result = general.redirect() + writeToFile((result as Map<String, Any>)["result"] as String) + + try { + mock = general.upload( + "string", + 123, + listOf("string in array"), + InputFile.fromPath("../../../resources/file.png") + ) + writeToFile(mock.result) + } catch (ex: Exception) { + writeToFile(ex.toString()) + } + + try { + mock = general.upload( + "string", + 123, + listOf("string in array"), + InputFile.fromPath("../../../resources/large_file.mp4") + ) + writeToFile(mock.result) + } catch (ex: Exception) { + writeToFile(ex.toString()) + } + + try { + var bytes = File("../../../resources/file.png").readBytes() + mock = general.upload( + "string", + 123, + listOf("string in array"), + InputFile.fromBytes(bytes, "file.png", "image/png") + ) + writeToFile(mock.result) + } catch (ex: Exception) { + writeToFile(ex.toString()) + } + + try { + var bytes = File("../../../resources/large_file.mp4").readBytes() + mock = general.upload( + "string", + 123, + listOf("string in array"), + InputFile.fromBytes(bytes, "large_file.mp4", "video/mp4") + ) + writeToFile(mock.result) + } catch (ex: Exception) { + writeToFile(ex.toString()) + } + + mock = general.enum(MockType.FIRST) + writeToFile(mock.result) + + try { + general.error400() + } catch (e: AppwriteException) { + writeToFile(e.message) + } + + try { + general.error500() + } catch (e: AppwriteException) { + writeToFile(e.message) + } + + try { + general.error502() + } catch (e: AppwriteException) { + writeToFile(e.message) + } + + delay(5000) + writeToFile(realtimeResponse) + + // mock = general.setCookie() + // writeToFile(mock.result) + + // mock = general.getCookie() + // writeToFile(mock.result) + + general.empty() + + // Query helper tests + writeToFile(Query.equal("released", listOf(true))) + writeToFile(Query.equal("title", listOf("Spiderman", "Dr. Strange"))) + writeToFile(Query.notEqual("title", "Spiderman")) + writeToFile(Query.lessThan("releasedYear", 1990)) + writeToFile(Query.greaterThan("releasedYear", 1990)) + writeToFile(Query.search("name", "john")) + writeToFile(Query.isNull("name")) + writeToFile(Query.isNotNull("name")) + writeToFile(Query.between("age", 50, 100)) + writeToFile(Query.between("age", 50.5, 100.5)) + writeToFile(Query.between("name", "Anna", "Brad")) + writeToFile(Query.startsWith("name", "Ann")) + writeToFile(Query.endsWith("name", "nne")) + writeToFile(Query.select(listOf("name", "age"))) + writeToFile(Query.orderAsc("title")) + writeToFile(Query.orderDesc("title")) + writeToFile(Query.cursorAfter("my_movie_id")) + writeToFile(Query.cursorBefore("my_movie_id")) + writeToFile(Query.limit(50)) + writeToFile(Query.offset(20)) + writeToFile(Query.contains("title", listOf("Spider"))) + writeToFile(Query.contains("labels", listOf("first"))) + writeToFile( + Query.or( + listOf( + Query.equal("released", listOf(true)), + Query.lessThan("releasedYear", 1990) + ) + ) + ) + writeToFile( + Query.and( + listOf( + Query.equal("released", listOf(false)), + Query.greaterThan("releasedYear", 2015) + ) + ) + ) + + // Permission & Roles helper tests + writeToFile(Permission.read(Role.any())) + writeToFile(Permission.write(Role.user(ID.custom("userid")))) + writeToFile(Permission.create(Role.users())) + writeToFile(Permission.update(Role.guests())) + writeToFile(Permission.delete(Role.team("teamId", "owner"))) + writeToFile(Permission.delete(Role.team("teamId"))) + writeToFile(Permission.create(Role.member("memberId"))) + writeToFile(Permission.update(Role.users("verified"))) + writeToFile(Permission.update(Role.user(ID.custom("userid"), "unverified"))) + writeToFile(Permission.create(Role.label("admin"))) + + // ID helper tests + writeToFile(ID.unique()) + writeToFile(ID.custom("custom_id")) + + mock = general.headers() + writeToFile(mock.result) + } + } + + private fun writeToFile(string: String?) { + val text = "${string ?: ""}\n" + File("result.txt").appendText(text) + } + + private fun parse(json: String): String? { + return try { + json.fromJson<Map<String, Any>>()["result"] as? String + } catch (exception: Exception) { + null + } + } +}